article banner

Gra w węża, część 2: Zmiana stanu

Cześć! To jest fragment książki Python od podstaw, która ma pomóc w nauce programowania od zera. Znajdziesz ją na Allegro, w Empikach i w księgarniach internetowych.

W tym rozdziale definiujemy zachowanie węża oraz jego jedzenia.

Na chwilę zapomnijmy zupełnie o PyGame i tych wszystkich kołach i kwadratach. Zachowajmy się jak profesjonalni programiści i zaimplementujmy zachowanie naszej gry w oderwaniu od tego, jak ona wygląda. Nasza gra może być reprezentowana przez następujące zmienne:

  • snake - lista przechowująca pozycję elementów węża (gdzie pierwszy element to sam koniec, a ostatni element jest głową),
  • food - pozycja jedzenia,
  • direction - w którym kierunku wąż się kieruje,
  • field_size - jaka jest wielkość planszy.

W odpowiedzi na kliknięcia użytkownika zmieniać się będzie wyłącznie direction. Cała reszta zmienia się niejako sama. W każdym kroku (dajmy na to, co 100 ms), wąż poruszy się w zadanym kierunku. Jeśli natrafił na jedzenie, to wydłuży się, a nowe jedzenie pojawi się na planszy w losowym miejscu. Jeśli zaś natrafi na własny ogon, to wracamy do punktu wyjścia. Tak toczy się życie naszego węża i to właśnie zaimplementujemy w tym rozdziale.

Zapomnijmy na chwilę o tym, co już napisaliśmy i utwórzmy zupełnie nowy plik o nazwie game_state.py. Wewnątrz niego, utworzymy klasę GameState, która reprezentować będzie cały stan naszej gry. Powinna więc zawierać snake, food, directionfield_size.

class GameState:
    def __init__(self,
                 snake,
                 direction,
                 food,
                 field_size):
        self.snake = snake
        self.direction = direction
        self.food = food
        self.field_size = field_size

Z czego direction reprezentować będziemy poprzez kolejne liczby odpowiadające kolejnym możliwym kierunkom. Jedną z praktyk jest przechowywanie takich liczb w dedykowanej klasie, w której wszystkie atrybuty są statyczne1. Umieśćmy ją w osobnym pliku direction.py.

class Direction:
    UP = 1
    DOWN = 2
    LEFT = 3
    RIGHT = 4

Wracając do klasy GameState, potrzebujemy metody step, która będzie wykonywała kolejno wszystko, co powinno się wydarzyć co określony czas. Skąd jednak będziemy wiedzieli, czy nasza implementacja tej funkcji jest poprawna? Na to pytanie odpowiedzą nam testy jednostkowe.

Testy jednostkowe

Jedną z istotniejszych koncepcji we współczesnym programowaniu jest testowanie jednostkowe. Polega ono na pisaniu funkcji, które sprawdzają, czy napisany przez nas kod jest poprawny. W testach zazwyczaj najpierw przedstawiamy, jaki jest stan początkowy, następnie jaka akcja została wykonana, a ostatecznie badamy, czy nastąpiła spodziewana zmiana stanu. Dla przykładu, stanem początkowym może być wąż w określonym miejscu i poruszający się w określonym kierunku, akcją wywołanie funkcji step, a sprawdzeniem zbadanie, czy nowa pozycja węża jest poprawna.

Tak mógłby wyglądać przykładowy test jednostkowy, który mógłby się znaleźć w metodzie klasy testowej:

# określenie stanu początkowego
state = GameState(
    snake=[
        Position(4, 0),
        Position(4, 1),
        Position(4, 2)
    ],
    direction=Direction.DOWN,
    food=Position(10, 10),
    field_size=20
)

# wywołanie badanej funkcji
state.step()

#  sprawdzenie wyniku działania funkcji
expected_state = [
    Position(4, 1),
    Position(4, 2),
    Position(4, 3)
]
self.assertEqual(expected_state, state.snake)

Punkt (4, 2) i jego otoczenie.

Do testów jednostkowych wykorzystamy pakiet unittest. Wymaga on, żeby nasze testy były metodami klasy o nazwach zaczynających się od "test_". W większych projektach tworzy się wiele klas z testami, ale u nas wystarczy tylko jedna. Na początek napiszemy bardzo prosty test, który sprawdza poprawność działania funkcji upper. Używa metody assertEqual na self, która porównuje dwa obiekty i w przypadku, gdyby nie były identyczne, wyświetla stosowny błąd. Tutaj używamy jej do porównania wyniku działania funkcji oraz spodziewanej wartości. Jeśli funkcja upper działa dobrze, to test przejdzie. Sprawdźmy to. Utwórzmy plik game_state_test.py z następującą zawartością:

import unittest


class GameStateTest(unittest.TestCase):

    def test_example(self):
        self.assertEqual('foo'.upper(), 'FOO')

Aby test działał, konieczne jest po nazwie klasy użycie nawiasu i wartości unittest.TestCase. Jest to funkcjonalność znana jako dziedziczenie. Także metody zawierające testy powinny zaczynać się od test_.

Nasz test najłatwiej będzie uruchomić poprzez zielony trójkąt po lewej od klasy.

Jeśli przycisk uruchomienia testów się nie pojawi, można je również uruchomić z kodu poprzez funkcję unittest.main().

if __name__ == '__main__':
    unittest.main()

Aby upewnić się, czy nasz test działa, zmieńmy jeden ze stringów. Ponieważ po tej zmianie test będzie niepoprawnie napisany, powinien zakończyć się porażką. Test, który nie przeszedł, zostanie wyraźnie zaznaczony na liście testów. Także metoda assertEqual powinna pokazać szczegóły na temat przekazanych jej wartości, co w normalnych przypadkach ułatwia badanie przyczyn niepowodzenia testu.

Wiemy już, jak przetestować napisany przez nas kod. W dalszych krokach stosować będę technikę znaną jako TDD (test-driven development). Polega ona na tym, że przed zaimplementowaniem funkcjonalności, najpierw piszemy test sprawdzający tę zmianę. Po napisaniu testu uruchomimy wszystkie testy, by upewnić się, że nowy test nie przechodzi (bo funkcjonalność nie została jeszcze napisana). Następnie zaczynamy pracę nad nową funkcjonalnością, a status tych prac badamy przez uruchamianie testów. Poprzez uruchamianie wszystkich testów, a nie tylko tego dotyczącego nowej funkcjonalności, nie tylko wiemy, czy nowa funkcjonalność działa poprawnie, ale także czy nie popsuliśmy niczego, co działało wcześniej. Przypomina to wspinaczkę z asekuracją.

Wąż się porusza

W każdym kroku gry, nasz wąż powinien się poruszać w kierunku, w którym jest zwrócony. Jak to przetestować? Wystarczy umieścić węża w dowolnym miejscu oraz z określonym kierunkiem, po czym sprawdzić, czy rzeczywiście poruszył się on zgodnie z oczekiwaniami. Przykładowe testy poruszania się w prawo i w lewo mogą więc wyglądać tak:

import unittest
from game_state import GameState
from position import Position
from direction import Direction


class GameStateTest(unittest.TestCase):

    def test_snake_should_move_right(self):
        state = GameState(
            snake=[
                Position(1, 2),
                Position(1, 3),
                Position(1, 4)
            ],
            direction=Direction.RIGHT,
            food=Position(10, 10),
            field_size=20
        )

        state.step()

        expected_state = [
            Position(1, 3),
            Position(1, 4),
            Position(2, 4)
        ]
        self.assertEqual(expected_state, state.snake)

    def test_snake_should_move_left(self):
        state = GameState(
            snake=[
                Position(1, 2),
                Position(1, 3),
                Position(1, 4)
            ],
            direction=Direction.LEFT,
            food=Position(10, 10),
            field_size=20
        )

        state.step()

        expected_state = [
            Position(1, 3),
            Position(1, 4),
            Position(0, 4)
        ]
        self.assertEqual(expected_state, state.snake)

Po napisaniu testów uruchom je najpierw, by zobaczyć czy zadziałają oraz jak wyglądać będzie efekt ich działania. Dzięki funkcjom __str____repr__ w obiekcie Position, powinniśmy otrzymać czytelne porównanie jak wygląda wąż w czasie sprawdzania, oraz jak powinien on wyglądać.

Testy poruszania w górę i w dół napisz już samemu.

Jak to zaimplementować? Zastanów się, co się dzieje z częściami naszego węża. Sama końcówka ogona (czyli pierwszy element) znika. To można zaimplementować poprzez zamianę węża na jego pozycje z zakresu od 1 do końca. W ten sposób wykluczamy wyłącznie pierwszy element i pozbywamy się końcówki ogona.

self.snake = self.snake[1:]

Z drugiej strony, na początku pojawia się nowy element. Jego pozycja zależy jednak od kierunku ruchu węża. Element możemy dodać przy użyciu funkcji append, a jego umiejscowienie określimy w osobnej funkcji next_head.

new_head = self.next_head(self.direction)
self.snake.append(new_head)

Funkcja next_head powinna zwracać nową pozycję głowy węża. Przyda się więc określenie jej aktualnej pozycji. Jest to ostatni element w zmiennej snake, a więc snake[-1]. Następnie powinniśmy stworzyć pozycję przesuniętą w danym kierunku. Spójrzmy ponownie na siatkę współrzędnych.

Punkt (4, 2) i jego otoczenie.

Jeśli nowa pozycja głowy powinna być u góry, to od zmiennej y powinniśmy odjąć 1. Jeśli u dołu, to dodać 1. Jeśli po lewej, to od zmiennej x powinniśmy odjąć 1. Jeśli po prawej, to dodać 1. Zaimplementować to możemy przy użyciu konstrukcji if z elif.

# Metoda w klasie GameState
def next_head(self, direction):
    pos = self.snake[-1]
    if direction == Direction.UP:
        return Position(pos.x, pos.y - 1)
    elif direction == Direction.DOWN:
        return Position(pos.x, pos.y + 1)
    elif direction == Direction.RIGHT:
        return Position(pos.x + 1, pos.y)
    elif direction == Direction.LEFT:
        return Position(pos.x - 1, pos.y)

Ta funkcja w zasadzie mogłaby mieć własne testy. Nie jest to jednak konieczne, bo pośrednio sprawdzamy jej działanie przy testowaniu ruchu węża.

Użyjemy tej metody w step.

# Metoda w klasie GameState
def step(self):
    new_head = self.next_head(self.direction)
    self.snake.append(new_head)
    self.snake = self.snake[1:]

Teraz wszystkie nasze testy powinny przejść. Sprawdź to. Nie wyczerpaliśmy jednak tematu ruchu węża, bo co, gdy dojedziemy do końca planszy?

Wyjechanie poza planszę

W klasycznej grze w węża, gdy dotarło się do końca planszy, albo gra się kończyła, albo wąż wyłaniał się z drugiej strony. Ten drugi wariant uważam za ciekawszy, więc to jego zaimplementujemy.

Zacznijmy jednak od testów. Aby sprawdzić, czy wąż wyskakuje z drugiej strony, umieśćmy go na samym końcu planszy i postawmy krok. Następnie sprawdźmy, czy jego głowa jest już po drugiej stronie.

# Nowe metody w klasie GameStateTest
def test_snake_should_move_up_on_top(self):
    state = GameState(
        snake=[
            Position(2, 2),
            Position(2, 1),
            Position(2, 0)
        ],
        direction=Direction.UP,
        food=Position(10, 10),
        field_size=20
    )

    state.step()

    expected_state = [
        Position(2, 1),
        Position(2, 0),
        Position(2, 19)
    ]
    self.assertEqual(expected_state, state.snake)


def test_snake_should_move_right_on_edge(self):
    state = GameState(
        snake=[
            Position(17, 1),
            Position(18, 1),
            Position(19, 1)
        ],
        direction=Direction.RIGHT,
        food=Position(10, 10),
        field_size=20
    )

    state.step()

    expected_state = [
        Position(18, 1),
        Position(19, 1),
        Position(0, 1)
    ]
    self.assertEqual(expected_state, state.snake)

Testy przejścia przez dół i lewą ścianę napisz już samemu.

By nasz kod zadziałał, w zasadzie potrzebna jest nam tylko drobna modyfikacja przy okeślaniu nowych współrzędnych w funkcji next_head. Jeśli po zmianie dowolna współrzędna wynosić miałaby -1, to zamiast tego powinna wynieść field_size - 1, czyli domyślnie 19. Jeśli miałaby wynieść 20, to powinna wynieść 0. Pamiętasz operator reszty z dzielenia %? Aby przypomnieć jak on działa 123 % 5 wyniesie 3, ponieważ największą równą wielokrotnością liczby 5 mniejszą od 123 jest 120. Pozostaje więc liczba 3 jako reszta z dzielenia. Co istotne, 20 % 20 == 0, a -1 % 20 == 19. Tak więc ten operator idealnie wpisuje się w nasze potrzeby. Po dodaniu lub odjęciu od pozycji x czy y powinniśmy wykonać operację reszty z dzielenia przez field_size.

# Metoda w klasie GameState
def next_head(self, direction):
    pos = self.snake[-1]
    if direction == Direction.UP:
        return Position(
            pos.x,
            (pos.y - 1) % self.field_size
        )
    elif direction == Direction.DOWN:
        return Position(
            pos.x,
            (pos.y + 1) % self.field_size
        )
    elif direction == Direction.RIGHT:
        return Position(
            (pos.x + 1) % self.field_size,
            pos.y
        )
    elif direction == Direction.LEFT:
        return Position(
            (pos.x - 1) % self.field_size,
            pos.y
        )

Ponownie wszystkie nasze testy powinny przejść. Przekonaj się o tym samemu.

Zjadanie kulek

Kiedy wąż zje kulkę, powinien urosnąć, a nowe jedzenia powinno pojawić się w losowym miejscu na planszy. Zacznijmy od pierwszej części tego zdania. Jeśli ustawimy węża tak, że w następnym kroku dotrze do jedzenia, to po wywołaniu funkcji step, powinien być on dłuższy. Moglibyśmy to sprawdzić poprzez porównanie poprzedniej i nowej długości węża, ale jeszcze precyzyjniej będzie zbadać, czy nowy stan zmiennej snake jest taki jak spodziewany.

# Nowa metoda w klasie GameStateTest
def test_snake_eats_food(self):
    state = GameState(
        snake=[
            Position(1, 2),
            Position(2, 2),
            Position(3, 2)
        ],
        direction=Direction.UP,
        food=Position(3, 1),
        field_size=20
    )

    state.step()

    expected_state = [
        Position(1, 2),
        Position(2, 2),
        Position(3, 2),
        Position(3, 1)
    ]
    self.assertEqual(expected_state, state.snake)

Zauważ, że jedyną zmianą względem poprzednich kroków jest to, że nie pozbywamy się ogona węża. Tak właśnie wąż się wydłuża, dostawiona zostaje głowa, ale ogon pozostaje bez zmian. Czyli warunek jest prosty: powinniśmy się pozbyć końcówki ogona tylko wtedy, jeśli nowa pozycja głowy nie jest pozycją, w której znajduje się jedzenie.

# Metoda w klasie GameState
def step(self):
    new_head = self.next_head(self.direction)
    self.snake.append(new_head)
    found_food = new_head == self.food
    if not found_food:
        self.snake = self.snake[1:]

Po tej zmianie wszystkie nasze testy ponownie powinny przechodzić. Czas jednak nadać jedzeniu nową pozycję. Przetestowanie ustawiania losowej pozycji jest bardzo trudne, więc poprzestańmy na sprawdzeniu, że po tym, jak dotarliśmy głową do pozycji jedzenia, znalazło ono nową lokalizację poza wężem. Do tego wystarczy dodać następujące sprawdzenie na końcu napisanego ostatnio testu:

self.assertEqual(False, state.food in state.snake)

Jak to zaimplementować? W ostatnim kroku nauczyliśmy się już jak sprawdzić, czy wąż znalazł jedzenie. Jeśli je znalazł, to powinniśmy nadać jedzeniu nową pozycję. Możemy to połączyć z poprzednio napisanym warunkiem w jedną instrukcję if z blokiem else. Ustawienia nowej pozycji jedzenia dokonamy w osobnej funkcji set_random_food_position.

# Metoda w klasie GameState
def step(self):
    new_head = self.next_head(self.direction)

    self.snake.append(new_head)

    if new_head == self.food:
        self.set_random_food_position()
    else:
        self.snake = self.snake[1:]

W rozdziale Organizacja projektu i importowanie poznaliśmy pakiet random i jej funkcję randint pozwalającą na wylosowanie liczby całkowitej w określonym zakresie. Jako argumenty podajemy minimalną i maksymalną wartość. Minimalną wartością xy powinno być 0, a maksymalną field_size - 1. Tak więc proste określanie wartości losowej mogłoby wyglądać następująco:

from random import randint


# Nowa metoda w klasie GameState
def set_random_food_position(self):
    self.food = Position(
        randint(0, self.field_size - 1),
        randint(0, self.field_size - 1)
    )

Skoro jest to jednak zupełnie losowa lokalizacja, nie daje nam to gwarancji, że nowa pozycja jedzenia nie będzie znajdowała się wewnątrz węża. Jest kilka sposobów jak rozwiązać ten problem, ale najprostszym jest sprawdzenie, czy pozycja jest poprawna i w przeciwnym wypadku ponowne losowanie. Możemy do tego użyć pętli while, ale możemy też zwyczajnie zawołać funkcję ponownie:

# Metoda w klasie GameState
def set_random_food_position(self):
    self.food = Position(
        randint(0, self.field_size - 1),
        randint(0, self.field_size - 1)
    )
    if self.food in self.snake:
        self.set_random_food_position()


# albo
def set_random_food_position(self):
    search = True
    while search:
        self.food = Position(
            randint(0, self.field_size - 1),
            randint(0, self.field_size - 1)
        )
        search = self.food in self.snake

Po zdefiniowaniu tej funkcji nasze testy ponownie powinny przechodzić. Wąż już potrafi się poruszać oraz zjadać jedzenie. To zaś będzie pojawiać się w losowym miejscu. Istny raj, czego chcieć więcej. Niestety wąż przyczynił się do zjedzenia owocu zakazanego i teraz musi doświadczać śmierci.

Śmierć węża

Gdy wąż wpadnie na własny ogon, powinien wrócić do stanu początkowego (po wszechwieczność). Do tej pory nie określiliśmy początkowej pozycji ani kierunku, ale chyba czas to zrobić. Określę je jako zmienną w pliku game_state.py.

# Nowe zmienne w pliku game_state.py
INITIAL_SNAKE = [
    Position(1, 2),
    Position(2, 2),
    Position(3, 2)
]
INITIAL_DIRECTION = Direction.RIGHT

W teście wartości te możemy zaimportować, by sprawdzić, że zostały one użyte po kolizji węża z własnym ogonem. Do tego dodam sprawdzenie, że jedzenie nie znajduje się wewnątrz węża oraz że wielkość planszy nie uległa przypadkiem zmianie (to nie jest jednak konieczne).

# Nowa metoda w klasie GameStateTest
def test_snake_dies(self):
    state = GameState(
        snake=[
            Position(1, 2),
            Position(2, 2),
            Position(3, 2),
            Position(3, 3),
            Position(2, 3),
        ],
        direction=Direction.UP,
        food=Position(3, 1),
        field_size=25
    )

    state.step()

    from game_state import INITIAL_SNAKE
    self.assertEqual(INITIAL_SNAKE, state.snake)
    self.assertFalse(state.food in state.snake)
    from game_state import INITIAL_DIRECTION
    self.assertEqual(INITIAL_DIRECTION, state.direction)
    self.assertEqual(25, state.field_size)

Importowanie wartości INITIAL_SNAKEINITIAL_DIRECTION może zostać przeniesione na początek pliku, ale jeśli wartość jest potrzebna tylko w jednym miejscu, to praktyką jest również umieszczenie importu przed jedynym użyciem. Import tworzy obiekt, więc najważniejsze by został uruchomiony przed pierwszym użyciem.

Pierwszym co musimy zrobić, jest sprawdzenie, czy doszło do kolizji. Jak tego dokonać? W funkcji step znamy już nową pozycję głowy oraz pozycje wszystkich części węża. Teraz wystarczy sprawdzić, czy ta nowa pozycja głowy nie jest identyczna, jak dowolna z części węża.

# Wewnątrz metody step
collision = new_head in self.snake

Jeśli jest, to doszło do zderzenia. W takim przypadku powinniśmy ustawić węża na pozycji początkowej oraz zrezygnować z pozostałych etapów ruchu. Poprzez słowo kluczowe return możemy zakończyć wywołanie funkcji step w tym kroku.

# Metoda w klasie GameState
def step(self):
    new_head = self.next_head(self.direction)

    collision = new_head in self.snake
    if collision:
        self.set_initial_position()
        return

    self.snake.append(new_head)

    if new_head == self.food:
        self.set_random_food_position()
    else:
        self.snake = self.snake[1:]

Pozostaje zaimplementować set_initial_position. Powinna ona ustawiać węża na jego pozycję początkową, ustawiać początkowy kierunek i losować nowe miejsce dla jedzenia. Przy ustawianiu węża z INITIAL_SNAKE warto wykonać kopię poprzez [:], by zabezpieczyć tą zmienną przed zmianami wewnętrznymi. Bez tego nasze wywołanie appendstep mogłoby zmodyfikować INITIAL_SNAKE, co doprowadziłoby do niepoprawnego resetowania stanu gry.

# Nowa metoda w klasie GameState
def set_initial_position(self):
    self.snake = INITIAL_SNAKE[:]
    self.direction = INITIAL_DIRECTION
    self.set_random_food_position()

Stan na koniec

W zasadzie zakończyliśmy pracę nad tym, co najważniejsze: zachowaniem węża i jedzenia. Potwierdzają to testy. Cóż jednak z tego, gdy w grę wciąż nie pogramy. W następnym rozdziale użyjemy napisanych klas i sprawimy, że w grę wreszcie będziemy mogli zagrać. Tak powinna wyglądać klasa GameState:

from position import Position
from direction import Direction
from random import randint

INITIAL_SNAKE = [
    Position(1, 2),
    Position(2, 2),
    Position(3, 2)
]
INITIAL_DIRECTION = Direction.RIGHT


class GameState:
    def __init__(self,
                 snake=None,
                 direction=INITIAL_DIRECTION,
                 food=None,
                 field_size=20):
        if snake is None:
            snake = INITIAL_SNAKE[:]
        self.snake = snake
        self.direction = direction
        self.field_size = field_size

        if food is None:
            self.set_random_food_position()
        else:
            self.food = food

    def set_initial_position(self):
        self.snake = INITIAL_SNAKE[:]
        self.direction = INITIAL_DIRECTION
        self.set_random_food_position()

    def next_head(self, direction):
        pos = self.snake[-1]
        if direction == Direction.UP:
            return Position(
                pos.x,
                (pos.y - 1) % self.field_size
            )
        elif direction == Direction.DOWN:
            return Position(
                pos.x,
                (pos.y + 1) % self.field_size
            )
        elif direction == Direction.RIGHT:
            return Position(
                (pos.x + 1) % self.field_size,
                pos.y
            )
        elif direction == Direction.LEFT:
            return Position(
                (pos.x - 1) % self.field_size,
                pos.y
            )

    def set_random_food_position(self):
        search = True
        while search:
            self.food = Position(
                randint(0, self.field_size - 1),
                randint(0, self.field_size - 1)
            )
            search = self.food in self.snake

    def can_turn(self, direction):
        new_head = self.next_head(direction)
        return new_head != self.snake[-2]

    def step(self):
        new_head = self.next_head(self.direction)

        collision = new_head in self.snake
        if collision:
            self.set_initial_position()
            return

        self.snake.append(new_head)

        if new_head == self.food:
            self.set_random_food_position()
        else:
            self.snake = self.snake[1:]
1:

Czyli przywiązane do klasy, a nie obiektu, jak opisywaliśmy w rozdziale Klasy i obiekty.