Django + WebSocket - implementacja gry Czwórki
W niniejszym wpisie chciałbym przedstawić Wam jak za pomocą WebSocket zbudować web aplikację działającą w czasie rzeczywistym. Dla odróżnienia od wielu przykładów, które można spotkać w sieci, nie będzie to aplikacja czatu, a będzie to prosta gra Connect 4 przeznaczona dla dwóch użytkowników (graczy). Zanim przejdziemy do projektowania i pisania samej aplikacji krótko przedstawię czym w ogóle jest WebSecket.
Krótko o WebSocket
WebSocket jest to protokół komunikacyjny umożliwiający dwukierunkową komunikację za pośrednictwem jednego gniazda TCP. Przeglądarka internetowa inicjuje połączenie TCP z serwerem (które jest dwukierunkowe) ale znajdujący się o warstwę wyżej protokół HTTP nie wykorzystuje możliwości transmisji dwukierunkowej. Nie pozwala na przykład na przesyłanie wiadomości typu push od serwera do klienta - działa na zasadzie klient pyta, serwer odpowiada. Istnieją pewne rozwiązania, które naśladują komunikację dwustronną w czasie rzeczywistym poprzez protokół HTTP (np. polling - który polega na tym, że skrypt przeglądarki w ustalonych interwałach czasowych odpytuje serwer o nowe dane/zdarzenia, za każdym odpytaniem zestawiając połączenie). Rozwiązania te jednak są czasochłonne i niejednokrotnie generują niepotrzebne obciążenie sieci.
WebSocket rozwiązuje wyżej wymienione problemy, gdyż tworzy on w przeglądarce tzw. gniazdo (socket), które utrzymuje obustronny kanał komunikacyjny z serwerem. Do zestawienia takiego gniazda WebSocket wykorzystuje funkcję zmiany protokołu poprzez jego aktualizację. Dzięki temu można wysyłać dane w dwóch kierunkach poprzez jedno trwałe połączenie TCP. W chwili obecnej wszystkie znane i popularne przeglądarki internetowe wspierają WebSocket bez potrzeby instalacji dodatkowych komponentów.
Gra Connect Four
Gra Czwórki (Connect Four) to planszowa gra przeznaczona dla dwóch osób, w której wykorzystuje się planszę o wymiarach 7x6 pól. Pierwszy gracz wrzuca swój żeton do wybranej kolumny, żeton zawsze zajmuje najniższą możliwą pozycję w kolumnie. Gracze wrzucają swoje żetony naprzemiennie, do momentu, aż jeden z nich ułoży cztery żetony z rzędu w poziomie, pionie lub na ukos.
Znamy już zasady, teraz przystąpimy do zaprojektowania architektury naszej gry. Potrzebujemy serwera (aplikacji webowej) oraz klienta, który będzie działał po stronie przeglądarki internetowej. Gracz łącząc się z adresem URL serwera gry spowoduje zestawienie kanału WebSocket pomiędzy przeglądarką i serwerem, który będzie wykorzystywany do przesyłania komunikatów o przebiegu i stanie gry. Po podłączeniu drugiego gracza rozpocznie się właściwa gra.
Aplikacja Serwera
Jako serwer dla naszej aplikacji wykorzystamy Django wraz z biblioteką Channels, która daje możliwość łatwego korzystania z WebSocket. Zaczniemy od zainstalowania wszystkiego co potrzebne:
Przygotowanie środowiska i instalacja zależności
Tworzenie plików projektu
Gdy mamy już gotowe środowisko wirtualne Python i zainstalowane zależności możemy stworzyć projekt:
W wyniku działania powyższych komend powinniśmy otrzymać następującą strukturę plików:
Łączenie Django z Channels
Mając gotowy kod projektu, przystąpimy do pisania części odpowiedzialnej za współpracę Django z Channels w wersji 2.
W ustawieniach projektu musimy ustawić wartość ASGI_APPLICATION aby wskazywała na naszą główną aplikację ASGI zarządzającą routingiem w obrębie WebSocketów. Kod takiej aplikacji ASGI wygląda następująco:
Obsługa logiki gry
W momencie, gdy Django przyjmuje żądanie HTTP, na podstawie konfiguracji ścieżek URL znajduje odpowiednią funkcję widoku i ją wywołuje aby obsłużyła to żądanie. Podobnie ma się rzecz z biblioteką Channels, gdy przyjmuje ona połączenie WebSocket to na podstawie konfiguracji routingu znajduje odpowiedniego konsumenta (consumer) i wówczas wywołuje różne jego metody celem obsługi zdarzeń z zestawionego połączenia.
Jak widać powyżej określiliśmy, że dla połączenia WebSocket ze ścieżką /ws będziemy delegować obsługę do konsumenta GameConsumer. Klasę konsumenta wyprowadzimy z klasy JsonWebsockerConsumer aby w łatwy sposób przesyłać komunikaty w formacie JSON poprzez ustanowione połączenie.
Z racji tego, że dla każdego połączenia WebSocket tworzona jest oddzielna instancja klasy konsumenta GameConsumer (dla dwóch graczy będziemy posiadać dwie instancje GameConsumer), musimy stworzyć dodatkową klasę Connect4, która będzie niejako ponad konsumentami i zawierać będzie obsługę logiki gry. Aby nie korzystać z warstw kanałów (channel_layers) /co skomplikowałoby nieco kod/ instancje konsumentów wstrzykniemy do klasy Connect4. Dzięki temu zabiegowi będziemy mieli możliwość wysyłania komunikatów równocześnie do obu graczy.
Stwórzmy klasę Connect4 i zainicjujmy jej właściwości:
Teraz rozbudujmy klasę o metodę player_join(). Metoda ta będzie wywoływana zawsze w momencie nawiązania połączenia WebSocket - wynika to z kodu konsumenta (metoda connect).
W momencie podłączenia się pierwszego gracza, zostanie mu przypisany kolor red (czerwony), o czym aplikacja klienta zostanie poinformowana za pomocą stosownego komunikatu color. Połączenie kolejnego klienta spowoduje przydzielenie mu koloru yellow (żółty) i analogicznie wysłanie do niego informacji o przydzielonym kolorze.
Po podłączeniu gracza, zawsze następuje sprawdzenie, czy posiadmy już podłączonych dwóch graczy. Jeśli tak, to do obydwu graczy wysyłamy informację o tym, który player teraz ma wykonać ruch (zaczyna zawsze gracz czerwony).
Gdy któryś z graczy rozłączy się w trakcie gry, to “zwalniamy” jego miejsce (poprzez wpisanie wartości None w self.players). W momencie zestawienia nowego połączenienia do serwera nowy gracz zostaje “umieszczony” w zwolnionym miejscu i gra może być kontynuowana.
W czasie trwania rozgrywki oczekujemy na informację zwrotną od aplikacji gracza (komunikat WebSocket), w której kolumnie gracz umieścił żeton.
Każdy otrzymany komunikat poprzez WebSocket powoduje wywołanie metody receive_json() odpowiedniego konsumenta. W przypadku, gdy jest to komunikat click odczytujemy numer kolumny i przekazujemy go dalej do obiektu obsługującego logikę gry.
Na początku metody player_click() dokonujemy kilku sprawdzeń (czy komunikat kliknięcia pochodzi od gracza, który aktualnie może wykonać ruch, i czy mamy obydwu graczy podłączonoych). Następnie poszukujemy miejsca (wiersza) dla żetonu w kolumnie wskazanej przez gracza i umieszczamy żeton w tym miejscu na planszy.
Następnie do obu graczy wysyłamy informację o stanie planszy, dzięki czemu oprogramowanie klienta będzie mogło zaktualizować stan rozgrywki.
Teraz przychodzi pora na sprawdzenie, czy umieszczony żeton nie spowodował wygrania rozgrywki przez aktualnego gracza (metoda check_victory()). Jeśli nastąpiło zwycięstwo to gracze są o tym informowani stosownym komunikatem. Gdy gra pozostaje nierozstrzygnięta to nastepuje przełączenie możliwości wykonanania ruchu na drugiego z graczy.
Dla formalności jeszcz został do przedstawienia fragment odpowiedzialny za sprawdzenie zwycięstwa:
Aplikacja Klienta
Klienta gry napiszemy w JavaScript z wykorzystaniem biblioteki Vue w wersji 2.5. Zaczniemy od stworzenia widoku w Django i przygotowania szablonu HTML:
W folderze projektu stwórzmy sobie katalog vue, który zawierał będzie kod klienta gry.
Entrypoint
Do obsługi WebSocket po stronie JS skorzystamy z biblioteki vue-native-websocket. Zainicjujemy ją na starcie poprzez określenie URL socketa i ustawienie formatu przesyłania komunikatów na JSON.
Główna Aplikacja
Zaczniemy od napisania komponentu, reprezentującego planszę gry. Grafikę planszy oprzemy na elemencie svg.
Plansza gry składa się z komponentów typu BoardColumn ułożonych obok siebie. Kliknięcie na taki kompnent powoduje wysłanie komunikatu click wraz z numerem kolumny poprzez WebSocket. Dodatkowo za pomocą CSS ustawiamy wygląd kursora myszy w zależności od tego, czy w danym momencie dany gracz może wykonać ruch (kliknąć) czy nie.
Komponent do wyświetlania informacji wygląda jak poniżej:
Mając wszystkie komponenty możemy przystapić do poukładania ich na stronie. Od góry: tytuł, informacje, plansza.
Dopisujemy teraz sekcję script:
Z istotnych rzeczy, które znajdują się w powyższym kodzie zwróćmy uwagę na sekcję mounted(). W tym miejscu tworzymy funkcję do obsługi komunikatów, która będzie wywoływana gdy zostaną do klienta wysłane poprzez WebSocket jakiekolwiek dane. Poniżej kod funkcji przetwarzającej otrzymane komunikaty.
Jak pamiętamy z wcześniej pisanego kodu dla serwera, po podłączeniu klienta zostaje mu nadany kod koloru i wysłany stosownym komunikatem: {‘color’: ‘…’}. Klient po otrzyamaniu kodu koloru umieszcza go w danych w zmiennej color. Po dołączeniu drugiego gracza (lub później w trakcie rozgrywki - naprzemiennie) serwer wysyła komunikat o tym, do którego gracza należy teraz ruch {‘turn’: ‘…’}. Wpływa to na informację wyświetlaną w komponencie Message. Otrzymanie komunikatu {‘board’: …} powoduje przypisanie otrzymanego stanu planszy, a komunikat {‘victory’: ‘…’} z kolei umożliwia wyświetlenie informacji ponad planszą o tym, który z graczy wygrał rozgrywkę.
Budowanie aplikacji klienta
W zasadzie napisaliśmy kompletny kod klienta gry. Pozostało nam jeszcze zbudowanie pakietu z aplikacją klienta. Skorzystamy tutaj z programu Parcel, który zrobi to dla nas automatycznie.
Ważne! Parcel w trakcie budowania stosuje optymalizację dla elementów SVG, co w tym wypadku może powodować błędne wyświetlanie planszy. Aby wyłączyć optymalizację SVG podczas budowania należy utworzyć w katalogu głównym projektu plik .htmlnanorc z następującą zawartością:
Możemy przeprowadzić już rozgrwykę:
Otwieramy przeglądarkę i w dwóch oknach (lub zakładkach) wpisujemy url: http://127.0.0.1:8000 i możemy grać :).
Dla osób zainteresowanych udostępniam oczywiście kod źródłowy całości projektu.
Zostaw komentarz