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#!/usr/bin/env python
 2# -*- coding: utf-8 -*-
 3
 4# import sys
 5import os
 6from random import randint
 7from time import sleep
 8import mcpi.minecraft as minecraft  # import modułu minecraft
 9import mcpi.block as block  # import modułu block
10
11os.environ["USERNAME"] = "Steve"  # nazwa użytkownika
12os.environ["COMPUTERNAME"] = "mykomp"  # nazwa komputera
13
14mc = minecraft.Minecraft.create("192.168.1.10")  # połączenie z MCPi
15
16
17class GraWZycie(object):
18    """
19    Łączy wszystkie elementy gry w całość.
20    """
21
22    def __init__(self, mc, szer, wys, ile=40):
23        """
24        Przygotowanie ustawień gry
25        :param szer: szerokość planszy mierzona liczbą komórek
26        :param wys: wysokość planszy mierzona liczbą komórek
27        """
28        self.mc = mc
29        mc.postToChat('Gra o zycie')
30        self.szer = szer
31        self.wys = wys
32
33    def uruchom(self):
34        """
35        Główna pętla gry
36        """
37        self.plac(0, 0, 0, self.szer, self.wys)  # narysuj pole gry
38
39    def plac(self, x, y, z, szer=20, wys=10):
40        """
41        Funkcja tworzy plac gry
42        """
43        podloga = block.STONE
44        wypelniacz = block.AIR
45        granica = block.OBSIDIAN
46
47        # granica, podłoże, czyszczenie
48        self.mc.setBlocks(
49            x - 5, y, z - 5,
50            x + szer + 5, y + max(szer, wys), z + wys + 5, wypelniacz)
51        self.mc.setBlocks(
52            x - 1, y - 1, z - 1, x + szer + 1, y - 1, z + wys + 1, granica)
53        self.mc.setBlocks(x, y - 1, z, x + szer, y - 1, z + wys, podloga)
54        self.mc.setBlocks(
55            x, y, z, x + szer, y + max(szer, wys), z + wys, wypelniacz)
56
57
58if __name__ == "__main__":
59    gra = GraWZycie(mc, 20, 10, 40)  # instancja klasy GraWZycie
60    mc.player.setPos(10, 20, -5)
61    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# magiczne liczby używane do określenia czy komórka jest żywa
 62DEAD = 0
 63ALIVE = 1
 64BLOK_ALIVE = 35  # block.WOOL
 65
 66
 67class Populacja(object):
 68    """
 69    Populacja komórek
 70    """
 71
 72    def __init__(self, mc, ilex, iley):
 73        """
 74        Przygotowuje ustawienia populacji
 75        :param mc: obiekt Minecrafta
 76        :param ilex: rozmiar x macierzy komórek (wiersze)
 77        :param iley: rozmiar y macierzy komórek (kolumny)
 78        """
 79        self.mc = mc
 80        self.iley = iley
 81        self.ilex = ilex
 82        self.generacja = self.reset_generacja()
 83
 84    def reset_generacja(self):
 85        """
 86        Tworzy i zwraca macierz pustej populacji
 87        """
 88        # wyrażenie listowe tworzy x kolumn o y komórkach
 89        # wypełnionych wartością 0 (DEAD)
 90        return [[DEAD for y in xrange(self.iley)] for x in xrange(self.ilex)]
 91
 92    def losuj(self, ile=50):
 93        """
 94        Losowo wypełnia macierz żywymi komórkami, czyli wartością 1 (ALIVE)
 95        """
 96        for i in range(ile):
 97            x = randint(0, self.ilex - 1)
 98            y = randint(0, self.iley - 1)
 99            self.generacja[x][y] = ALIVE
100        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        self.populacja = Populacja(mc, szer, wys)  # instancja klasy Populacja
33        if ile:
34            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    def rysuj(self):
104        """
105        Rysuje komórki na planszy, czyli umieszcza odpowiednie bloki
106        """
107        print "Rysowanie macierzy..."
108        for x, z in self.zywe_komorki():
109            podtyp = randint(0, 15)
110            mc.setBlock(x, 0, z, BLOK_ALIVE, podtyp)
111
112    def zywe_komorki(self):
113        """
114        Generator zwracający współrzędne żywych komórek.
115        """
116        for x in range(len(self.generacja)):
117            kolumna = self.generacja[x]
118            for y in range(len(kolumna)):
119                if kolumna[y] == ALIVE:
120                    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    def sasiedzi(self, x, y):
129        """
130        Generator zwracający wszystkich okolicznych sąsiadów
131        """
132        for nx in range(x - 1, x + 2):
133            for ny in range(y - 1, y + 2):
134                if nx == x and ny == y:
135                    continue  # pomiń współrzędne centrum
136                if nx >= self.ilex:
137                    # sąsiad poza końcem planszy, bierzemy pierwszego w danym
138                    # rzędzie
139                    nx = 0
140                elif nx < 0:
141                    # sąsiad przed początkiem planszy, bierzemy ostatniego w
142                    # danym rzędzie
143                    nx = self.ilex - 1
144                if ny >= self.iley:
145                    # sąsiad poza końcem planszy, bierzemy pierwszego w danej
146                    # kolumnie
147                    ny = 0
148                elif ny < 0:
149                    # sąsiad przed początkiem planszy, bierzemy ostatniego w
150                    # danej kolumnie
151                    ny = self.iley - 1
152
153                # zwróć stan komórki w podanych współrzędnych
154                yield self.generacja[nx][ny]
155
156    def nast_generacja(self):
157        """
158        Generuje następną generację populacji komórek
159        """
160        print "Obliczanie generacji..."
161        nast_gen = self.reset_generacja()
162        for x in range(len(self.generacja)):
163            kolumna = self.generacja[x]
164            for y in range(len(kolumna)):
165                # pobieramy wartości sąsiadów
166                # dla żywej komórki dostaniemy wartość 1 (ALIVE)
167                # dla martwej otrzymamy wartość 0 (DEAD)
168                # zwykła suma pozwala nam określić liczbę żywych sąsiadów
169                iluS = sum(self.sasiedzi(x, y))
170                if iluS == 3:
171                    # rozmnażamy się
172                    nast_gen[x][y] = ALIVE
173                elif iluS == 2:
174                    # przechodzi do kolejnej generacji bez zmian
175                    nast_gen[x][y] = kolumna[y]
176                else:
177                    # za dużo lub za mało sąsiadów by przeżyć
178                    nast_gen[x][y] = DEAD
179
180        # nowa generacja staje się aktualną generacją
181        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    def uruchom(self):
37        """
38        Główna pętla gry
39        """
40        i = 0
41        while True:  # działaj w pętli do momentu otrzymania sygnału do wyjścia
42            print("Generacja: " + str(i))
43            self.plac(0, 0, 0, self.szer, self.wys)  # narysuj pole gry
44            self.populacja.rysuj()
45            self.populacja.nast_generacja()
46            i += 1
47            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    def wczytaj(self):
112        """
113        Funkcja wczytuje populację komórek z MC RPi
114        """
115        ileKom = 0
116        print "Proszę czekać, aktuzalizacja macierzy..."
117        for x in range(self.ilex):
118            for z in range(self.iley):
119                blok = self.mc.getBlock(x, 0, z)
120                if blok != block.AIR:
121                    self.generacja[x][z] = ALIVE
122                    ileKom += 1
123        print self.generacja
124        print "Żywych:", str(ileKom)
125        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        if ile:
34            self.populacja.losuj(ile)
35        else:
36            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:

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

Autorzy:

Patrz plik „Autorzy”