Django + WebSocket - implementacja gry Czwórki

11 minut(y)

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.

Architektura Gry

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

$ mkdir connect4
$ cd connect4
$ pipenv install django channels
Creating a Pipfile for this project…
Installing django…
Adding django to Pipfile's [packages]…
✔ Installation Succeeded
Installing channels…
Adding channels to Pipfile's [packages]…
✔ Installation Succeeded
Pipfile.lock not found, creating…
Locking [dev-packages] dependencies…
Locking [packages] dependencies…
✔ Success!
Updated Pipfile.lock (60a7f1)!
Installing dependencies from Pipfile.lock (60a7f1)…
  🐍   ▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉ 18/18 — 00:00:02
To activate this project's virtualenv, run pipenv shell.
Alternatively, run a command inside the virtualenv with pipenv run.

Tworzenie plików projektu

Gdy mamy już gotowe środowisko wirtualne Python i zainstalowane zależności możemy stworzyć projekt:

$ pipenv run django-admin startproject connect4 .
$ pipenv run python manage.py startapp game

W wyniku działania powyższych komend powinniśmy otrzymać następującą strukturę plików:

.
├── connect4
│   ├── __init__.py
│   ├── settings.py
│   ├── urls.py
│   └── wsgi.py
├── game
│   ├── admin.py
│   ├── apps.py
│   ├── __init__.py
│   ├── migrations
│   │   └── __init__.py
│   ├── models.py
│   ├── tests.py
│   └── views.py
├── manage.py
├── Pipfile
└── Pipfile.lock

Łą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.

# connect4/settings.py
...
INSTALLED_APPS = [
    ...
    'channels',
    'game',
]
...
ASGI_APPLICATION = "connect4.routing.application

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:

# connect4/routing.py
from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter
from channels.routing import URLRouter

from game import routing

application = ProtocolTypeRouter({
    'websocket': AuthMiddlewareStack(
        URLRouter(
            routing.websocket_urlpatterns
        )
    ),
})
# game/routing.py
from django.conf.urls import url

from . import consumers

websocket_urlpatterns = [
    url(r'^ws/$', consumers.GameConsumer),
]

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.

from asgiref.sync import async_to_sync
from channels.generic.websocket import JsonWebsocketConsumer

from .connect4 import Connect4

connect4 = Connect4()

class GameConsumer(JsonWebsocketConsumer):

    def connect(self):
        connect4.player_join(self)

    def disconnect(self, close_code):
        connect4.player_disconnect(self)

    def receive_json(self, data):
        if 'click' in data:
            column = data.get('click')
            connect4.player_click(self, column)

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:

# game/connect4.py

class Connect4:
    counter = 0
    board = None
    players = {'red': None, 'yellow': None}
    player = 'red'

    def __init__(self):
        self.reset()

    def reset(self):
        self.players['red'] = None
        self.players['yellow'] = None
        self.player = 'red'
        self.board = [['white' for i in range(6)] for j in range(7)]

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).

# game/connect4.py
class Connect4:
    ...

    def is_both_players_connected(self):
        return self.players['red'] is not None and self.players['yellow'] is not None

    def send_to_players(self, command, message):
        self.players['red'].send_json({command: message})
        self.players['yellow'].send_json({command: message})

    def player_disconnect(self, consumer):
        if self.players['red'] == consumer:
            self.players['red'] = None

        if self.players['yellow'] == consumer:
            self.players['yellow'] = None

    def player_join(self, consumer):
        if self.players['red'] is None:
            self.players['red'] = consumer
            consumer.accept()
            consumer.send_json({'color': 'red'})
        elif self.players['yellow'] is None:
            self.players['yellow'] = consumer
            consumer.accept()
            consumer.send_json({'color': 'yellow'})

        if self.is_both_players_connected():
            self.send_to_players('turn', self.player)
            self.send_to_players('board', self.board)

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.

# game/connect4.py
class Connect4:
    ...
    def player_click(self, consumer, column):
        if self.players[self.player] != consumer:
            print('click from wrong player: ' +
                  'yellow' if self.player == 'red' else 'red')
            return

        if not self.is_both_players_connected():
            print('click before all players conected')
            return

        row = -1
        for row in range(5, -1, -1):
            if self.board[column][row] == 'white':
                self.board[column][row] = self.player
                break

        self.send_to_players('board', self.board)

        if self.check_victory(column, row):
            self.send_to_players('victory', self.player)
            self.reset()
            return

        self.player = 'red' if self.player == 'yellow' else 'yellow'
        self.send_to_players('turn', self.player)

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:

# game/connect4.py
class Connect4:
    ...
    def check_victory(self, col, row):

        def get_cell(col, row):
            if col < 0 or row < 0:
                return None
            if col > 7 or row > 5:
                return None
            return self.board[col][row]

        cell = get_cell(col, row)

        cnt = 0
        for i in range(1, 4):
            if get_cell(col - i, row) != cell:
                break
            cnt += 1
        for i in range(1, 4):
            if get_cell(col + i, row) != cell:
                break
            cnt += 1
        if cnt > 2:
            return True

        cnt = 0
        for i in range(1, 4):
            if get_cell(col, row - i) != cell:
                break
            cnt += 1
        for i in range(1, 4):
            if get_cell(col, row + i) != cell:
                break
            cnt += 1
        if cnt > 2:
            return True

        cnt = 0
        for i in range(1, 4):
            if get_cell(col - i, row - i) != cell:
                break
            cnt += 1
        for i in range(1, 4):
            if get_cell(col + i, row + i) != cell:
                break
            cnt += 1
        if cnt > 2:
            return True

        cnt = 0
        for i in range(1, 4):
            if get_cell(col - i, row + i) != cell:
                break
            cnt += 1
        for i in range(1, 4):
            if get_cell(col + i, row - i) != cell:
                break
            cnt += 1
        if cnt > 2:
            return True

        return False

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:

# game/views.py
from django.shortcuts import render
from django.views import generic

class HomeView(generic.TemplateView):
    template_name = 'home.html'
# connect4/urls.py
from django.urls import path
from game import views

urlpatterns = [
    path('', views.HomeView.as_view(), name='home')
]
# game/templates/home.html
{% load static %}
<html>
    <head>
      <link rel="stylesheet" href="{% static 'main.css' %}">
    </head>
    <body>
        <div id="app"></div>
        <script
            src="https://code.jquery.com/jquery-3.3.1.min.js"
            integrity="sha256-FgpCb/KJQlLNfOu91ta32o/NMZxltwRo8QtmkMRdAu8="
            crossorigin="anonymous"></script>
        <script src="{% static 'main.js' %}"></script>
    </body>
</html>

W folderze projektu stwórzmy sobie katalog vue, który zawierał będzie kod klienta gry.

Entrypoint

# vue/index.js
import Vue from 'vue'
import App from './App'
import VueNativeSock from 'vue-native-websocket'

Vue.use(VueNativeSock, 'ws://127.0.0.1:8000/ws/', {format: "json"})
Vue.config.productionTips = false

new Vue({
    render: h => h(App)
}).$mount('#app')

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.

# vue/components/Board.vue
<template>
    <div>
        <svg width="390" height="360" xmlns="http://www.w3.org/2000/svg">
            <board-column v-for="(columnData, idx) in board"
                @click.native="onColumnClick(idx)"
                :data="columnData"
                :your-turn="yourTurn"
                :column="idx">
            </board-column>
            <polygon points="20,300 0,360 390, 360 370, 300" fill="blue" />
        </svg>
    </div>
</template>

<script>
import BoardColumn from './BoardColumn'
export default {
    name: 'Board',
    props: {
        board: {},
        yourTurn: false
    },
    methods: {
        onColumnClick: function(column) {
            this.$socket.sendObj({'click': column})
        }
    },
    components: {
        BoardColumn
    },
}
</script>
# vue/components/BoardColumn.vue
<template>
    <g>
        <g v-for="(columnData, idx) in data" :style="style">
            <rect :x="20 + column * 50" :y="idx * 50" width="50" height="50" fill='blue'></rect>
            <circle :cx="20 + 24 + column * 50" :cy="24 + idx * 50" r="15" :fill="columnData"></circle>
        </g>
    </g>
</template>
<script>
export default {
    name: 'BoardColumn',
    props: {
        data: {},
        column: 0,
        yourTurn: false
    },
    computed: {
        style() {
            return {cursor: this.yourTurn ? 'pointer' : 'no-drop'}
        }
    },
}
</script>

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:

<template>
    <div :class="color" class="message">
        
    </div>
</template>

<script>
export default {
    props: {
        message: {},
        color: {}
    }
}
</script>

Mając wszystkie komponenty możemy przystapić do poukładania ich na stronie. Od góry: tytuł, informacje, plansza.

# vue/App.vue
<template>
    <div>
        <div class="title">
            Connect Four
        </div>
        <message :color="color" :message="message"></message>
        <div class="board">
            <board :board="board" :your-turn="yourTurn"></board>
        </div>
    </div>
</template>
...

Dopisujemy teraz sekcję script:

...
<script>
import Board from './components/Board'
import Message from './components/Message'
export default {
    data: () => ({
        board: Array(7).fill(0).map(x=> Array(6).fill('white')),
        message: 'Waiting for another player...',
        yourTurn: false,
        color: null
    }),
    methods: {
      processMessage: function(message) {
        ...
      }
    },
    mounted: function() {
        const self = this
        this.$options.sockets.onmessage = (data) => {
            const message=JSON.parse(data.data)
            self.procesMessage(message)
        }
    },
    components: {
        Board,
        Message
    }
}
</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.

  procesMessage: function(message) {
      if (message.hasOwnProperty('color')) {
          this.color = message['color']
      }
      if (message.hasOwnProperty('turn')) {
          const player = message['turn']
          if (player === this.color) {
              this.message = "You're up. What's your move?"
              this.yourTurn = true
          }
          else {
              this.message = player + " is thinking..."
              this.yourTurn = false
          }
      }
      if (message.hasOwnProperty('board')) {
          this.board = message['board']
      }
      if (message.hasOwnProperty('victory')) {
          const winner = message['victory']
          if (winner === this.color) {
              this.message = 'You win!'
          }
          else {
              this.message = 'You lose!'
          }
      }
  }

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.

$ parcel build vue/index.js --out-dir game/static --out-file main.js
Built in 13.15s.

game/static/main.map    343.81 KB      59ms
game/static/main.js      74.39 KB    12.95s
game/static/main.css        356 B    12.55s

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ą:

{
  minifySvg: false,
}

Możemy przeprowadzić już rozgrwykę:

$ pipenv run python manage.py runserver

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