article banner

Gra w węża, część 3: Przejmujemy sterowanie

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 używamy zaimplementowanej logiki, by wreszcie można było zagrać w naszą grę.

Ponieważ mamy już zaimplementowaną logikę naszej gry, wystarczy teraz jej użyć. Wróćmy do pliku game.py. Nie obejdzie się bez zaimportowania Position, DirectionGameState.

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

Na początku potrzebujemy stworzyć obiekt z klasy GameState i ustawić na nim wartości początkowe. Do ustawiania tych wartości mamy już funkcję set_initial_position. Możemy więc wypełnić snake, directionfood dowolnymi wartościami, po czym zawołać set_initial_position by zamienić je na poprawne wartości początkowe.

state = GameState(
    snake=None,
    direction=None,
    food=None,
    field_size=CUBES_NUM,
)
state.set_initial_position()

W naszej pętli moglibyśmy teraz postawić krok, a następnie wyświetlić nowy stan przy użyciu zaimplementowanej wcześniej funkcji draw.

while True:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            quit()

    state.step()
    draw(state.snake, state.food)

Jeśli jednak tak zrobimy, to nasz wąż będzie się poruszał tak szybko, że aż będzie się rozmywał. Trzeba zapanować nad jego szybkością, a dokonamy tego poprzez obiekt pygame.time.Clock(). Gdy wywołamy jego funkcję tick w naszej pętli, to spowolni ona to ciało tak, by wywoływało się konkretną liczbę razy na sekundę. Jeśli więc wywołamy tick(10) w pętli, to wąż postawi 10 kroków na sekundę. Na początek jest to dobra prędkość gry. Aby ułatwić lub utrudnić zabawę, możesz tę wartość zmniejszyć bądź zwiększyć.

clock = pygame.time.Clock()
while True:
    clock.tick(10)

    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            quit()

    state.step()
    draw(state.snake, state.food)

Jeśli teraz uruchomimy naszą grę, to wąż powinien się poruszać. Ponieważ jednak wciąż nim nie sterujemy, będzie krążył w kółko. Czas więc przejąć sterowanie.

Sterowanie

Kiedy wciśniemy przycisk na klawiaturze, informacja o tym fakcie przekazywana jest jako zdarzenie, czyli po angielsku "event". Przy każdej iteracji pętli możemy nasłuchiwać1 na takie zdarzenia. Powiem więcej, na jedno już nasłuchujemy. Aktualnie nasłuchujemy na zdarzenie zakończenia gry, czas zacząć nasłuchiwać także na wciśnięcie interesujących nas przycisków.

W PyGame używamy funkcji pygame.event.get() by pobrać wszystkie zdarzenia, jakie nastąpiły od ostatniego zapytania. Zwraca ona obiekt, po którym możemy iterować w pętli for. Typowo więc używa się for event in pygame.event.get() by obsłużyć wszystkie zdarzenia, jakie się pojawiły. Ostatecznie w niemalże tym samym czasie gracz może na przykład nacisnąć strzałkę oraz zamknąć grę. Ponieważ nie chcemy zgubić żadnego zdarzenia, obsługujemy je kolejno.

Zdarzenie identyfikujemy przede wszystkim po jego typie, czyli atrybucie type. Jeśli jest on równy pygame.QUIT2, to oznacza, że gra została zamknięta. Zdarzenie oznaczające naciśnięcie przycisku ma typ pygame.KEYDOWN.

Jak już wiemy, że zdarzenie jest naciśnięciem przycisku, sprawdźmy, jaki to przycisk. Kod przycisku przechowywany jest w atrybucie key, a kody strzałki lewej, prawej, górnej i dolnej to kolejno pygame.K_LEFT, pygame.K_RIGHT, pygame.K_UPpygame.K_DOWN3. Zastosujemy więc kolejny if-elif do zdefiniowania, jak powinniśmy zareagować na każdą z interesujących nas strzałek.

while True:
    clock.tick(10)

    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            quit()

        elif event.type == pygame.KEYDOWN:
            if event.key == pygame.K_LEFT:
                state.direction = Direction.LEFT

            elif event.key == pygame.K_RIGHT:
                state.direction = Direction.RIGHT

            elif event.key == pygame.K_UP:
                state.direction = Direction.UP

            elif event.key == pygame.K_DOWN:
                state.direction = Direction.DOWN

    state.step()
    draw(state.snake, state.food)

Czy mogę zrobić ten krok?

Teraz w naszą grę już można zagrać, ale wciąż ma pewne problemy. Nie wszystkie kierunki ruchu są dozwolone. Wąż może poruszać się do przodu albo skręcić w prawo lub w lewo. Nie może jednak się cofać. Pomyśl o sytuacji, w której wąż przemieszcza się w prawo i wtedy gracz wciska strzałkę ruchu w lewo. Co by się stało? Głowa nastąpiłaby na szyję, czyli na część węża, co oznaczałoby koniec gry. A więc dla każdego kierunku ruchu jedna ze strzałek oznacza samobójstwo węża. Gra z takim ograniczeniem byłaby nieco frustrująca. By temu zapobiec, powinniśmy zablokować ruchy w niedozwolonym kierunku.

Wykorzystamy do tego funkcję turn, która będzie zmieniać kierunek ruchu węża, ale tylko wtedy, jeśli nowy kierunek jest dozwolony. Zacznijmy od testu, który sprawdzi, czy nasza funkcja działa. Umieścimy węża w tej samej pozycji, co na chwilę przed śmiercią w teście test_snake_dies. Chodzi o to, żeby nasza funkcja sprawdzała tylko, czy wąż nie wpada we własną szyję, ale pozwalała na wpadnięcie we własny ogon. W tej konfiguracji wąż może skręcić w górę (co oznacza jego śmierć), w lewo i w dół. Nie może jednak skręcić w prawo, bo stamtąd przychodzi.

# Nowa metoda w klasie GameStateTest
def test_turn(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.turn(Direction.LEFT)
    self.assertEqual(Direction.LEFT, state.direction)

    state.turn(Direction.UP)
    self.assertEqual(Direction.UP, state.direction)

    state.turn(Direction.DOWN)
    self.assertEqual(Direction.DOWN, state.direction)

    state.turn(Direction.RIGHT)
    # Pozostaje stara wartość,
    # bo nie można skręcić w prawo
    self.assertEqual(Direction.DOWN, state.direction)

Logika tej funkcji jest prosta. Jeśli wąż może się zwrócić w danym kierunku, to powinien tak właśnie zrobić.

# Nowa metoda w klasie GameState
def turn(self, direction):
    if self.can_turn(direction):
        self.direction = direction

Metoda ta zależy od can_turn, na której skupimy się teraz. Ponownie jednak zacznijmy od testu.

# Nowa metoda w klasie GameStateTest
def test_can_turn(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
    )

    self.assertEqual(
        True,
        state.can_turn(Direction.LEFT)
    )
    self.assertEqual(
        True,
        state.can_turn(Direction.UP)
    )
    self.assertEqual(
        True,
        state.can_turn(Direction.DOWN)
    )
    self.assertEqual(
        False,
        state.can_turn(Direction.RIGHT)
    )

Jak taką metodę napisać? Jak już wspominaliśmy, najłatwiej jest sprawdzić, czy nowa pozycja głowy nie jest identyczna jak pozycja szyi, czyli kawałka zaraz przed głową. Szyja to snake[-2], nową pozycję głowy możemy określić przy pomocy next_head, a do porównania możemy użyć zwyczajnie znaku !=.

# Nowa metoda w klasie GameState
def can_turn(self, direction):
    new_head = self.next_head(direction)
    return new_head != self.snake[-2]

Gdy już mamy tę funkcję, wystarczy zmodyfikować sterowanie, aby zamiast zmieniać wartość zmiennej direction, używało metody turn. Oto nasza cała pętla gry, wraz z definicją stanu:

state = GameState(
    snake=None,
    direction=None,
    food=None,
    field_size=CUBES_NUM,
)
state.set_initial_position()

clock = pygame.time.Clock()
while True:
    clock.tick(10)

    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            quit()

        elif event.type == pygame.KEYDOWN:

            if event.key == pygame.K_LEFT:
                state.turn(Direction.LEFT)

            elif event.key == pygame.K_RIGHT:
                state.turn(Direction.RIGHT)

            elif event.key == pygame.K_UP:
                state.turn(Direction.UP)

            elif event.key == pygame.K_DOWN:
                state.turn(Direction.DOWN)

    state.step()
    draw(state.snake, state.food)

Zakończenie

To już ten czas. Trochę się namęczyliśmy, ale wreszcie możesz pograć w węża. Przebijesz mnie? Ja doszedłem do... w sumie to nie wiem, bo nie zrobiliśmy licznika punktów, ale do naprawdę długiego węża.

W tej grze możesz jeszcze wiele zrobić. Na przykład:

  • Dodać licznik punktów.
  • Sprawić, by wpadnięcie w koniec planszy zabijało węża.
  • Dodać kilka przeszkód na planszy.
  • Narysować wężowi oczy oraz poprawić wygląd ogona, planszy i jedzenia.
  • Spowolnić węża, ale przy tym sprawić, by przyspieszał z czasem.
  • Dodać możliwość zatrzymania gry (na przykład poprzez spację).
  • Dodać ekran z wynikiem po tym, jak wąż zginie.
  • Dodać listę najlepszych wyników (co wymaga zapisywania wyników na dysku).

Zamiast linku, możesz użyć tego kodu QR.

W projekcie, w folderze "1-daj-mi-okno-na-swiat" znajduje się kod, jaki powinien powstać po pierwszym rozdziale "Daj mi okno na świat", a w folderze "2-zmiana-stanu" znajduje się kod, jaki powinien powstać po drugim rozdziale "Zmiana stanu". Pozostałe pliki stanowią finalny kod gry.

1:

O nasłuchiwaniu w programowaniu mówimy wtedy, gdy definiujemy jakieś zachowanie, jakie ma się wydarzyć w przypadku wystąpienia określonego zdarzenia.

2:

Szczegółowe informacje na tematy typów zdarzeń znajdziesz w dokumentacji pakietu, ale także w odpowiedziach na forach, StackOverflow i innych miejscach dyskusji.

3:

Jeśli chcesz zareagować na inne klawisze, to wszystkie zaczynają się od K_. W przypadku liter i cyfr po tym następuje ta litera lub cyfra, np. K_2, K_a.