9.6. Gra w życie

Gra w życie jest najbardziej znaną implementacją automatu komórkowego, wymyśloną przez brytyjskiego matematyka Johna Conwaya. Cały pomysł polega na symulowaniu rozwoju populacji komórek, które umieszczone w wyznaczonym obszarze tworzą różne zaskakujące układy.

Grę zaimplementujemy przy użyciu programowania obiektowego, którego podstawowym elementem są klasy. Można je rozumieć jako definicje obiektów odwzorowujących mniej lub bardziej dokładniej jakieś elementy rzeczywistości, niekoniecznie materialne. Obiekty łączą dane, czy też właściwości, oraz metody na nich operujące. Obiekt tworzymy na podstawie klas i nazywamy je wtedy instancjami danej klasy.

9.6.1. Plansza gry

Zaczniemy od przygotowania obszaru, w którym będziemy obserwować kolejne populacje komórek. Tworzymy pusty plik w katalogu mcpi-sim i zapisujemy pod nazwą mcpi-glife.py. Wstawiamy do niego poniższy kod:

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
#!/usr/bin/env python
# -*- coding: utf-8 -*-

# import sys
import os
from random import randint
from time import sleep
import mcpi.minecraft as minecraft  # import modułu minecraft
import mcpi.block as block  # import modułu block

os.environ["USERNAME"] = "Steve"  # nazwa użytkownika
os.environ["COMPUTERNAME"] = "mykomp"  # nazwa komputera

mc = minecraft.Minecraft.create("192.168.1.10")  # połączenie z MCPi


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

    def __init__(self, mc, szer, wys, ile=40):
        """
        Przygotowanie ustawień gry
        :param szer: szerokość planszy mierzona liczbą komórek
        :param wys: wysokość planszy mierzona liczbą komórek
        """
        self.mc = mc
        mc.postToChat('Gra o zycie')
        self.szer = szer
        self.wys = wys

    def uruchom(self):
        """
        Główna pętla gry
        """
        self.plac(0, 0, 0, self.szer, self.wys)  # narysuj pole gry

    def plac(self, x, y, z, szer=20, wys=10):
        """
        Funkcja tworzy plac gry
        """
        podloga = block.STONE
        wypelniacz = block.AIR
        granica = block.OBSIDIAN

        # granica, podłoże, czyszczenie
        self.mc.setBlocks(
            x - 5, y, z - 5,
            x + szer + 5, y + max(szer, wys), z + wys + 5, wypelniacz)
        self.mc.setBlocks(
            x - 1, y - 1, z - 1, x + szer + 1, y - 1, z + wys + 1, granica)
        self.mc.setBlocks(x, y - 1, z, x + szer, y - 1, z + wys, podloga)
        self.mc.setBlocks(
            x, y, z, x + szer, y + max(szer, wys), z + wys, wypelniacz)


if __name__ == "__main__":
    gra = GraWZycie(mc, 20, 10, 40)  # instancja klasy GraWZycie
    mc.player.setPos(10, 20, -5)
    gra.uruchom()  # wywołanie metody uruchom()

Główna klasa w programie nazywa się GraWZycie, jej definicja rozpoczyna się słowem kluczowym class, a nazwa obowiązkową dużą literą. Pierwsza zdefiniowana metoda o nazwie __init__() to konstruktor klasy, wywoływany w momencie tworzenia jej instancji. Dzieje się tak w głównej funkcji main() w instrukcji: gra = GraWZycie(mc, 20, 10, 40). Tworząc instancję klasy, czyli obiekt gra, przekazujemy do konstruktora parametry: obiekt mc reprezentujący grę Minecraft, szerokość i wysokość pola gry, a także ilość tworzonych na wstępie komórek.

Konstruktor z przekazanych parametrów tworzy właściwości klasy w instrukcjach typu self.mc = mc. Do właściwości klasy odwołujemy się w innych metodach za pomocą słowa self – np. w wywołanej w funkcji głównej metodzie uruchom(). Jej zadaniem jest wykonanie metody plac(), która buduje planszę gry. Przekazujemy jej współrzędne punktu początkowego, a także szerokość i wysokość planszy.

Informacja

Warto zauważyć i zapamiętać, że każda metoda w klasie jako pierwszy parametr przyjmuje zawsze wskaźnik do instancji obiektu, na którym będzie działać, czyli konwencjonalne słowo self.

W wyniku uruchomienia i przetestowania kodu powinniśmy zobaczyć zbudowaną planszę do gry, czyli prostokąt, o podanych w funkcji głównej wymiarach.

../../_images/mcpi-glife01.png

9.6.2. Populacja

Utworzymy klasę Populacja, a w niej strukturę danych reprezentującą układ żywych i martwych komórek. Przed funkcją główną main() wstawiamy kod:

Kod nr
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
# magiczne liczby używane do określenia czy komórka jest żywa
DEAD = 0
ALIVE = 1
BLOK_ALIVE = 35  # block.WOOL


class Populacja(object):
    """
    Populacja komórek
    """

    def __init__(self, mc, ilex, iley):
        """
        Przygotowuje ustawienia populacji
        :param mc: obiekt Minecrafta
        :param ilex: rozmiar x macierzy komórek (wiersze)
        :param iley: rozmiar y macierzy komórek (kolumny)
        """
        self.mc = mc
        self.iley = iley
        self.ilex = ilex
        self.generacja = self.reset_generacja()

    def reset_generacja(self):
        """
        Tworzy i zwraca macierz pustej populacji
        """
        # wyrażenie listowe tworzy x kolumn o y komórkach
        # wypełnionych wartością 0 (DEAD)
        return [[DEAD for y in xrange(self.iley)] for x in xrange(self.ilex)]

    def losuj(self, ile=50):
        """
        Losowo wypełnia macierz żywymi komórkami, czyli wartością 1 (ALIVE)
        """
        for i in range(ile):
            x = randint(0, self.ilex - 1)
            y = randint(0, self.iley - 1)
            self.generacja[x][y] = ALIVE
        print self.generacja

Konstruktor klasy Populacja pobiera obiekt Minecrafta (mc) oraz rozmiary dwuwymiarowej macierzy (ilex, iley), czyli tablicy, która reprezentować będzie układy komórek. Po przypisaniu właściwościom klasy przekazanych parametrów tworzymy początkowy stan populacji, tj. macierz wypełnioną zerami. W metodzie reset_generacja() wykorzystujemy wyrażenie listowe, które – ujmując rzecz w terminologii Pythona – zwraca listę ilex list zawierających iley komórek z wartościami zero. To właśnie wspomniana wcześniej macierz dwuwymiarowa.

Ćwiczenie 1

Uruchom konsolę IPython Qt Console i wklej do niej polecenia:

DEAD, ilex, iley = 0, 5, 10
generacja = [[DEAD for y in xrange(10)] for ilex in xrange(5)]
generacja

Zobacz efekt (nie zamykaj konsoli, jeszcze się przyda):

../../_images/ipython01-glife.png

Komórki mogą być martwe (DEAD– wartość 0) i tak jest na początku, ale aby populacja mogła ewoluować, trzeba niektóre z nich ożywić (ALIVE – wartość 1). Odpowiada za to metoda losuj(), która przyjmuje jeden argument określający, ile komórek ma być początkowo żywych. Następnie w pętli losowana jest wymagana ilość par indeksów wskazujących wiersz i kolumnę, czyli komórkę, która ma być żywa (ALIVE). Na końcu drukujemy w terminalu początkowy układ komórek.

Ćwiczenie 2

Spróbuj w kilku komórkach macierzy utworzonej w konsoli, zapisać wartość ALIVE, czyli 1.

W konstruktorze klasy głównej GraWZycie tworzymy instancję klasy Populacja – to powoduje wykonanie jej konstruktora. Potem wywołujemy metodę tworzącą układ początkowy. Tak więc na końcu konstruktora klasy GraWZycie (__init__())dodajemy poniższy kod:

Kod nr
32
33
34
        self.populacja = Populacja(mc, szer, wys)  # instancja klasy Populacja
        if ile:
            self.populacja.losuj(ile)

Przetestuj kod.

9.6.3. Rysowanie macierzy

Skoro mamy przygotowany plac gry oraz początkowy układ populacji, trzeba ją narysować, czyli umieścić określone bloki we współrzędnych Minecrafta odpowiadających indeksom ożywionych komórek macierzy. Na końcu klasy Populacja dodajemy dwie nowe metody rysyj() i zywe_komorki():

Kod nr
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
    def rysuj(self):
        """
        Rysuje komórki na planszy, czyli umieszcza odpowiednie bloki
        """
        print "Rysowanie macierzy..."
        for x, z in self.zywe_komorki():
            podtyp = randint(0, 15)
            mc.setBlock(x, 0, z, BLOK_ALIVE, podtyp)

    def zywe_komorki(self):
        """
        Generator zwracający współrzędne żywych komórek.
        """
        for x in range(len(self.generacja)):
            kolumna = self.generacja[x]
            for y in range(len(kolumna)):
                if kolumna[y] == ALIVE:
                    yield x, y  # zwracamy współrzędne, jeśli komórka jest żywa

– a rysowanie wywołujemy w metodzie uruchom() klasy GraWZycie, dopisując:

Kod nr
41
        self.populacja.rysuj()

Wyjaśnienia wymaga funkcja rysuj(). W pętli pobieramy współrzędne żywych komórek, które rozpakowywane są z 2-elementowej listy do zmiennych: for x, z in self.zywe_komorki():. Dalej losujemy podtyp bloku bawełny i umieszczamy go we wskazanym miejscu.

Funkcja zywe_komorki() to tzw. generator, co poznajemy po tym, że zwraca wartości za pomocą słowa kluczowego yield. Jej działanie polega na przeglądaniu macierzy za pomocą zagnieżdżonych pętli i zwracaniu współrzędnych “żywych”komórek.

Ćwiczenie 3

Odwołując się do utworzonej wcześniej przykładowej macierzy, przetestuj w konsoli poniższy kod:

for x in range(len(generacja)):
    kolumna = generacja[x]
    for y in range(len(kolumna)):
        print x, y, " = ", generacja[x][y]

Różnica pomiędzy generatorem a zwykłą funkcją polega na tym, że zwykła funkcja po przeglądnięciu całej macierzy zwróciłaby od razu kompletną listę żywych komórek, a generator robi to “na żądanie”. Po napotkaniu żywej komórki zwraca jej współrzędne, zapamiętuje stan lokalnych pętli i czeka na następne wywołanie. Dzięki temu oszczędzamy pamięć, a dla dużych struktur także zwiększamy wydajność.

Uruchom kod, oprócz pola gry, powinieneś zobaczyć bloki reprezentujące pierwszą generację komórek.

../../_images/mcpi-glife03.png

9.6.4. Ewolucja – zasady gry

Jak można było zauważyć, rozgrywka toczy się na placu podzielonym na kwadratowe komórki, którego reprezentacją algorytmiczną jest macierz. Każda komórka ma maksymalnie ośmiu sąsiadów. To czy komórka przetrwa, zależy od ich ilości. Reguły są następujące:

  • Martwa komórka, która ma dokładnie 3 sąsiadów, staje się żywa w następnej generacji.
  • Żywa komórka z 2 lub 3 sąsiadami zachowuje swój stan, w innym przypadku umiera z powodu “samotności” lub “zatłoczenia”.

Kolejne generacje obliczamy w umownych jednostkach czasu. Do kodu klasy Populacja dodajemy dwie metody zawierające logikę gry:

Kod nr
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
    def sasiedzi(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:
                    continue  # pomiń współrzędne centrum
                if nx >= self.ilex:
                    # 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.ilex - 1
                if ny >= self.iley:
                    # 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.iley - 1

                # zwróć stan komórki w podanych współrzędnych
                yield self.generacja[nx][ny]

    def nast_generacja(self):
        """
        Generuje następną generację populacji komórek
        """
        print "Obliczanie generacji..."
        nast_gen = self.reset_generacja()
        for x in range(len(self.generacja)):
            kolumna = self.generacja[x]
            for y in range(len(kolumna)):
                # 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
                iluS = sum(self.sasiedzi(x, y))
                if iluS == 3:
                    # rozmnażamy się
                    nast_gen[x][y] = ALIVE
                elif iluS == 2:
                    # przechodzi do kolejnej generacji bez zmian
                    nast_gen[x][y] = kolumna[y]
                else:
                    # za dużo lub za mało sąsiadów by przeżyć
                    nast_gen[x][y] = DEAD

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

Metoda nast_generacja() wylicza kolejny stan populacji. Na początku tworzymy pustą macierz naste_gen wypełnioną zerami – tak jak w konstruktorze klasy. Następnie przy użyciu dwóch zagnieżdżonych pętli for – takich samych jak w generatorze zywe_komorki() – przeglądamy wiersze, wydobywając z nich kolejne komórki i badamy ich otoczenie.

Najważniejszy krok algorytmu to określenie ilości żywych sąsiednich komórek, co ma miejsce w instrukcji: iluS = sum(self.sasiedzi(x, y)). Funkcja sum() sumuje zapisane w sąsiednich komórkach wartości, zwracane przez generator sasiedzi(). Generator ten wykorzystuje zagnieżdżone pętle for, aby uzyskać współrzędne sąsiednich komórek, następnie w instrukcjach warunkowych if sprawdza, czy nie wychodzą one poza planszę.

Uwaga

“Gra w życie” zakłada, że symulacja toczy się na nieograniczonej planszy, jednak dla celów wizualizacji w MC Pi musimy przyjąć jakieś jej wymiary, a także podjąć decyzję, co ma się dziać, kiedy je przekraczamy. W naszej implementacji, kiedy badając stan sąsiada przekraczamy planszę, bierzemy pod uwagę stan komórki z przeciwległego końca wiersza lub kolumny.

Ćwiczenie 4

Na przykładzie utworzonej wcześniej macierzy przetestuj w konsoli kod:

x, y = 2, 2
for nx in range(x - 1, x + 2):
    for ny in range(y - 1, y + 2):
        print nx, ny, "=", generacja[nx][ny]

Jak widzisz, zwraca on wartości zapisane w komórkach otaczających wyznaczoną współrzędnymi x, y.

Wróćmy do metody nast_generacja(). Po wywołaniu iluS = sum(self.sasiedzi(x, y)), wiemy już, ilu mamy wokół siebie sąsiadów. Dalej za pomocą instrukcji warunkowych, np. if iluS == 3:, sprawdzamy więc ich ilość i – zgodnie z regułami – ożywiamy badaną komórkę, zachowujemy jej stan lub ją uśmiercamy. Uzyskany stan zapisujemy w nowej macierzy nast_gen. Po zbadaniu wszystkich komórek nowa macierz reprezentująca nową generację nadpisuje poprzednią: self.generacja = nast_gen. Pozostaje ją narysować. Zmieniamy metodę uruchom() klasy GraWZycie:

Kod nr
36
37
38
39
40
41
42
43
44
45
46
47
    def uruchom(self):
        """
        Główna pętla gry
        """
        i = 0
        while True:  # działaj w pętli do momentu otrzymania sygnału do wyjścia
            print("Generacja: " + str(i))
            self.plac(0, 0, 0, self.szer, self.wys)  # narysuj pole gry
            self.populacja.rysuj()
            self.populacja.nast_generacja()
            i += 1
            sleep(1)

Proces generowania i rysowania kolejnych generacji komórek dokonuje się w zmienionej metodzie uruchom() głównej klasy naszego skryptu. Wykorzystujemy nieskończoną pętlę while True:, w której:

  • rysujemy plac gry,
  • rysujemy aktualną populację,
  • wyliczamy następną generację,
  • wstrzymujemy działanie na sekundę
  • i wszystko powtarzamy.

Tak uruchomiony program możemy przerwać tylko “ręcznie” przerywając działanie skryptu.

Wskazówka

Uwaga: metoda zakończenia działania skryptu zależy od sposobu jego uruchomienia i systemu operacyjnego. Np. w Linuksie skrypt uruchomiony w terminalu poleceniem python skrypt.py przerwiemy naciskając CTRL+C lub bardziej radykalnie ALT+F4 (zamknięcie okna z terminalem).

Przetestuj skrypt!

../../_images/mcpi-glife04.png

9.6.5. Początek zabawy

Śledzenie ewolucji losowo przygotowanego układu komórek nie jest zazwyczaj zbyt widowiskowe, zwłaszcza kiedy symulację przeprowadzamy na dużej planszy. O wiele ciekawsza jest możliwość śledzenia zmian samodzielnie zaprojektowanego układu początkowego. Dodajmy więc możliwość wczytywania takiego układu bezpośrednio z Minecrafta. Do klasy Populacja poniżej metody losuj() dodajemy kod:

Kod nr
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
    def wczytaj(self):
        """
        Funkcja wczytuje populację komórek z MC RPi
        """
        ileKom = 0
        print "Proszę czekać, aktuzalizacja macierzy..."
        for x in range(self.ilex):
            for z in range(self.iley):
                blok = self.mc.getBlock(x, 0, z)
                if blok != block.AIR:
                    self.generacja[x][z] = ALIVE
                    ileKom += 1
        print self.generacja
        print "Żywych:", str(ileKom)
        sleep(3)

Działanie metody wczytaj() jest proste: za pomocą zagnieżdżonych pętli pobieramy typ bloku z każdego miejsca placu gry: blok = self.mc.getBlock(x, 0, z). Jeżeli na placu znajduje się jakikolwiek blok inny niż powietrze, oznaczamy odpowiednią komórkę początkowej generacji, wskazywaną przez współrzędną bloku jako żywą: self.generacja[x][z] = ALIVE. Przy okazji zliczamy ilość takich komórek.

Wywołanie funkcji trzeba dopisać do konstruktora klasy GraWZycie w następujący sposób:

Kod nr
33
34
35
36
        if ile:
            self.populacja.losuj(ile)
        else:
            self.populacja.wczytaj()

Jak widać wykonanie metody wczytaj() zależne jest od wartości parametru ile. Tak więc jeżeli chcesz przetestować nową możliwość, w wywołaniu konstruktora w funkcji głównej ustaw ten parametr na 0 (zero), np: gra = GraWZycie(mc, 30, 20, 0).

Informacja

Uwaga: przy dużych rozmiarach pola gry odczytywanie wszystkich bloków zajmuje dużo czasu! Przed testowaniem wczytywania własnych układów warto uruchomić skrypt przynajmniej raz, aby zbudować w MC Pi plac gry.

Nie pozostaje nic innego, jak zacząć się bawić. Można np. urządzić zawody: czyja populacja komórek utrzyma się dłużej – oczywiście warto wykluczyć budowanie znanych i udokumentowanych układów stałych.

../../_images/mcpi-glife05.png

Ćwiczenie 5

Dodaj do skryptu mechanizm kończący symulacji, kiedy na planszy nie ma już żadnych żywych komórek.

../../_images/mcpi-glife06.png

Źródła:


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”