Hobbit 2014

Dodanie rozgrywki WebRTC do Hobbita

Daniel Isaksson
Daniel Isaksson

Z okazji premiery filmu „Hobbit: Bitwa Pięciu Armii” rozszerzyliśmy zeszłoroczny eksperyment w Chrome Podróż przez Śródziemie o nowe treści. Tym razem skupiliśmy się na zwiększeniu możliwości przeglądarki WebGL, aby więcej przeglądarek i urządzeń mogło wyświetlać treści oraz korzystać z funkcji WebRTC w Chrome i Firefox. W tym eksperymencie mieliśmy 3 cele:

  • Rozgrywka P2P z użyciem WebRTC i WebGL w Chrome na Androida
  • Twórz łatwe w obsłudze gry wieloosobowe oparte na sterowaniu dotykowym
  • Hostowanie w Google Cloud Platform

Definiowanie gry

Logika gry opiera się na siatce, a wojska poruszają się po planszy. Dzięki temu mogliśmy przetestować rozgrywkę na papierze, gdy pracowaliśmy nad zasadami. Użycie konfiguracji opartej na siatce ułatwia też wykrywanie kolizji w grze, co pozwala zachować dobrą wydajność, ponieważ wystarczy sprawdzać kolizje z obiektmi na tej samej lub sąsiedniej planszy. Od początku wiedzieliśmy, że nowa gra będzie się koncentrować na walce między 4 głównymi siłami Śródziemia: ludźmi, krasnoludami, elfami i orkami. Musi też być na tyle swobodna, aby można było grać w ramach eksperymentu w Chrome. Nie wymagało też zbyt wielu interakcji, by można było się na niej uczyć. Na początek zdefiniowaliśmy 5 map bitewnych na mapie Śródziemia, które pełnią rolę sal gier, w których wielu graczy może rywalizować w bitwach jeden na jednego. Pokazywanie wielu graczy w pokoju na ekranie urządzenia mobilnego i umożliwianie użytkownikom wyboru, z kim będą rywalizować, było wyzwaniem samo w sobie. Aby ułatwić interakcję i scenę, zdecydowaliśmy, że będzie miał tylko 1 przycisk do wyzwania i zaakceptowania. W pomieszczeniu można pokazywać tylko wydarzenia i pokazywać, kto jest aktualnym królem wzgórza. Dzięki temu rozwiązaliśmy też kilka problemów związanych z dopasowywaniem i mogliśmy dobrać najlepszych kandydatów do walki. Podczas naszego poprzedniego eksperymentu w Chrome, dotyczącego gry Cube Slam, dowiedzieliśmy się, że radzenia sobie z opóźnieniami w grach wieloosobowych wymaga wiele pracy. Musisz wyrobić sobie założenia dotyczące tego, w jakim miejscu jest przeciwnik, a potem synchronizować to z animacjami na różnych urządzeniach. W tym artykule omawiamy te problemy bardziej szczegółowo. Aby ułatwić ten proces, wprowadziliśmy w tej grze tryb turowy.

Logika gry opiera się na siatce, a wojska poruszają się po planszy. Dzięki temu mogliśmy przetestować rozgrywkę na papierze, gdy pracowaliśmy nad zasadami. Użycie konfiguracji opartej na siatce ułatwia też wykrywanie kolizji w grze, co pozwala zachować dobrą wydajność, ponieważ wystarczy sprawdzać kolizje z obiektmi na tej samej lub sąsiedniej planszy.

Elementy gry

Aby przebudować tę grę wieloosobową, musimy zrealizować kilka kluczowych elementów:

  • Interfejs API do zarządzania graczami po stronie serwera obsługuje użytkowników, dobieranie graczy, sesje i statystyki gry.
  • Serwery ułatwiające nawiązywanie połączenia między odtwarzaczami.
  • Interfejs API do obsługi sygnalizacji interfejsu AppEngine Channels API służącej do łączenia się i komunikowania ze wszystkimi graczami w pokojach gier.
  • Silnik gier JavaScript, który obsługuje synchronizację stanu i przesyłanie wiadomości RTC między 2 graczami lub peerami.
  • Widok gry WebGL.

Zarządzanie odtwarzaczem

Aby obsłużyć dużą liczbę graczy, używamy wielu równoległych pomieszczeń gier na każdy Battleground. Głównym powodem ograniczania liczby graczy w pokoju gier jest umożliwienie nowym graczom znalezienia się na szczycie tabeli w rozsądnym czasie. Limit jest też powiązany z rozmiarem obiektu JSON opisującego pokój gry wysyłany przez Channel API, który ma limit 32 KB. Musimy przechowywać w grze graczy, pokoje, wyniki, sesje i ich relacje. W tym celu najpierw użyliśmy NDB do obsługi jednostek, a następnie interfejsu zapytań do obsługi relacji. NDB to interfejs Google Cloud Datastore. Na początku korzystanie z NDB było świetne, ale szybko napotkaliśmy problem związany z tym, jak go używać. Zapytanie zostało uruchomione w wersji bazy danych „zaakceptowanej” (zapisy NDB są szczegółowo opisane w tym obszernym artykule), która może mieć opóźnienie kilkusekundowe. Jednak same obiekty nie mają tego opóźnienia, ponieważ odpowiadają bezpośrednio z pamięci podręcznej. Być może łatwiej będzie to wyjaśnić za pomocą przykładowego kodu:

// example code to explain our issue with eventual consistency
def join_room(player_id, room_id):
    room = Room.get_by_id(room_id)
    
    player = Player.get_by_id(player_id)
    player.room = room.key
    player.put()
    
    // the player Entity is updated directly in the cache
    // so calling this will return the room key as expected
    player.room // = Key(Room, room_id)

    // Fetch all the players with room set to 'room.key'
    players_in_room = Player.query(Player.room == room.key).fetch()
    // = [] (an empty list of players)
    // even though the saved player above may be expected to be in the
    // list it may not be there because the query api is being run against the 
    // "committed" version and may still be empty for a few seconds

    return {
        room: room,
        players: players_in_room,
    }

Po dodaniu testów jednostkowych wyraźnie widać było problem, więc zrezygnowaliśmy z zapytań, a zamiast tego przechowywaliśmy relacje na liście rozdzielanych przecinkami w memcache. To było trochę nietypowe rozwiązanie, ale zadziałało. Memcache AppEngine ma system kluczy podobny do transakcji, który korzysta z rewelacyjnej funkcji „porównaj i ustaw”, więc testy znów się powiodły.

Niestety memcache nie składa się tylko z tęczy i jednorożców, ale ma kilka ograniczeń, z których najważniejsze to rozmiar 1 MB (nie może mieć zbyt wielu pokojów powiązanych z polem bitwy) i czas wygaśnięcia klucza. Jak wyjaśnia to w dokumentacji:

Rozważyliśmy użycie innego doskonałego magazynu par klucz-wartość – Redis. W tamtym czasie konfigurowanie skalowanego klastra było nieco przytłaczające, a ponieważ chcieliśmy się skupić na tworzeniu aplikacji, a nie na zarządzaniu serwerami, nie zdecydowaliśmy się na to rozwiązanie. Z drugiej strony Google Cloud Platform niedawno wprowadziła prostą funkcję Kliknij, aby wdrożyć, w której jednym z opcji jest klaster Redis. Byłaby to bardzo interesująca opcja.

W końcu znaleźliśmy Google Cloud SQL i przenieśliśmy relacje do MySQL. To była spora praca, ale ostatecznie wszystko zadziałało świetnie. Aktualizacje są teraz w pełni atomowe, a testy nadal się sprawdzają. Dzięki temu dopasowywanie i przyznawanie punktów jest znacznie bardziej niezawodne.

Z czasem coraz więcej danych zostało przeniesionych z NDB i memcache do bazy danych SQL, ale ogólnie elementy gracza, pola bitwy i sal są nadal przechowywane w NDB, a sesje i związki między nimi – w bazie danych SQL.

Musieliśmy też śledzić, kto gra, i dopasowywać graczy w różnych parach przy użyciu mechanizmu dopasowywania, który uwzględniał ich poziom umiejętności i doświadczenie. Dopasowywanie gier opiera się na bibliotece open source Glicko2.

Ponieważ jest to gra wieloosobowa, chcemy informować innych graczy w pokoju o wydarzeniach takich jak „kto wszedł lub wyszedł”, „kto wygrał lub przegrał” oraz czy jest wyzwanie do zaakceptowania. Aby rozwiązać ten problem, dodaliśmy do interfejsu Player Management API możliwość otrzymywania powiadomień.

Konfigurowanie WebRTC

Gdy 2 graczy zostaną dopasowani do walki, usługa sygnalizacji umożliwia im nawiązanie połączenia i rozmowę.

Do obsługi usługi sygnalizacji możesz użyć kilku bibliotek innych firm, co upraszcza konfigurowanie WebRTC. Niektóre opcje to PeerJS, SimpleWebRTC oraz PubNub WebRTC SDK. PubNub używa hostowanego rozwiązania serwerowego, a w przypadku tego projektu chcieliśmy hostować je w Google Cloud Platform. Pozostałe 2 biblioteki korzystają z serwerów node.js, które można zainstalować w Google Compute Engine, ale trzeba też zadbać o to, aby mogły obsługiwać tysiące jednoczesnych użytkowników. Wiedzieliśmy już, że interfejs Channel API ma taką możliwość.

W tym przypadku jedną z głównych zalet korzystania z Google Cloud Platform jest możliwość skalowania. Skalowanie zasobów potrzebnych do projektu App Engine można łatwo przeprowadzić w Google Developers Console. Nie trzeba też wykonywać żadnych dodatkowych czynności, aby skalować usługę sygnalizacji przy użyciu interfejsu Channels API.

Mieliśmy pewne obawy dotyczące opóźnień i niezawodności interfejsu Channels API, ale wcześniej używaliśmy go w projekcie CubeSlam i okazało się, że działa on dobrze w przypadku milionów użytkowników, więc postanowiliśmy go ponownie wykorzystać.

Nie chcieliśmy używać biblioteki zewnętrznej do obsługi WebRTC, więc musieliśmy stworzyć własną. Na szczęście mogliśmy wykorzystać to, co wykonaliśmy w ramach projektu CubeSlam. Gdy obaj gracze dołączą do sesji, sesja jest ustawiona jako „aktywna” i obaj gracze używają tego identyfikatora do inicjowania połączenia peer-to-peer przez interfejs Channel API. Następnie cała komunikacja między dwoma graczami będzie obsługiwana za pomocą kanału RTCDataChannel.

Potrzebujemy też serwerów STUN i TURN, które pomogą w nawiązywaniu połączenia oraz radzeniu sobie z sieciami NAT i zaporami sieciowymi. Więcej informacji o konfigurowaniu WebRTC znajdziesz w artykule HTML5 Rocks na temat WebRTC w świecie rzeczywistym: STUN, TURN i sygnalizowanie.

Liczba używanych serwerów TURN musi się też zmieniać w zależności od natężenia ruchu. Aby rozwiązać ten problem, przetestowaliśmy Menedżera wdrożeń Google. Umożliwia nam to dynamiczne wdrażanie zasobów w Google Compute Engine i instalowanie serwerów TURN za pomocą szablonu. Jest to wersja alfa, ale w naszym przypadku działała bez zarzutu. W serwerze TURN używamy coturn, czyli bardzo szybkiej, wydajnej i pozornie niezawodnej implementacji reguł STUN/TURN.

Channel API

Interfejs Channel API służy do wysyłania całej komunikacji do i z pokoju gier po stronie klienta. Nasze interfejsy API zarządzania graczami używają interfejsu Channel API do wysyłania powiadomień o zdarzeniach w grze.

Praca z interfejsem Channels API wiązała się z kilkoma problemami. Ponieważ wiadomości mogą być nieuporządkowane, na przykład musieliśmy je opakować w obiekcie i posortować. Oto przykładowy kod:

var que = [];  // [seq, packet...]
var seq = 0;
var rcv = -1;

function send(message) {
  var packet = JSON.stringify({
    seq: seq++,
    msg: message
  });
  channel.send(packet);
}

function recv(packet) {
  var data = JSON.parse(packet);

  if (data.seq <= rcv) {
    // ignoring message, older or already received
  } else if (data.seq > rcv + 1) {
    // message from the future. queue it up.
    que.push(data.seq, packet);
  } else {
    // message in order! update the rcv index and emit the message
    rcv = data.seq;
    emit('message', data.message);

    // and now that we have updated the `rcv` index we 
    // will check the que for any other we can send
    setTimeout(flush, 10);
  }
}

function flush() {
  for (var i=0; i<que.length; i++) {
    var seq = que[i];
    var packet = que[i+1];
    if (data.seq == rcv + 1) {
      recv(packet);
      return; // wait for next flush
    }
  }
}

Chcieliśmy też, aby różne interfejsy API witryny były modułowe i oddzielone od hostingu witryny. Zaczęliśmy od korzystania z modułów wbudowanych w GAE. Niestety po uruchomieniu wszystkiego w wersji deweloperskiej okazało się, że interfejs Channel API nie działa z modułami w wersji produkcyjnej. Zamiast tego zaczęliśmy używać oddzielnych instancji GAE, ale napotkaliśmy problemy z CORS, które zmusiły nas do użycia mostu postMessage w ramce iframe.

Silnik gry

Aby silnik gry był jak najbardziej dynamiczny, stworzyliśmy aplikację interfejsu na podstawie metody entity-component-system (ECS). Gdy rozpoczęliśmy prace nad rozwojem, nie mieliśmy jeszcze gotowych makiet ani specyfikacji funkcjonalnej, więc możliwość dodawania funkcji i logiki w trakcie rozwoju była bardzo przydatna. Na przykład w pierwszym prototypze wykorzystano prosty system renderowania kanw do wyświetlania elementów w siatce. Kilka iteracji później dodaliśmy system zderzeń i drugi dla graczy sterowanych przez AI. W połowie projektu mogliśmy przejść na system renderowania 3D bez zmiany reszty kodu. Gdy elementy sieciowe były gotowe do działania, system AI można było zmodyfikować, aby używać poleceń zdalnych.

Podstawowa logika gry wieloosobowej polega na wysyłaniu konfiguracji polecenia działania do drugiego użytkownika przez kanały danych i na zmuszeniu symulacji do działania tak, jakby była to sztuczna inteligencja. Gracz naciśnie przycisk przejścia lub ataku, a polecenie pojawi się w kolejce, gdy gracz zacznie patrzeć na poprzednią animację itd.

Gdyby było tylko 2 użytkowników, którzy się przełączają, obaj mogliby podzielić się odpowiedzialnością za przekazanie tury przeciwnikowi, gdy skończą, ale w tym przypadku jest jeszcze trzeci gracz. System AI okazał się przydatny (nie tylko do testowania), gdy musieliśmy dodać wrogów, takich jak pająki i trolle. Aby pasowały do rozgrywki turowej, musiały być generowane i wykonywane w taki sam sposób po obu stronach. Rozwiązaniem było umożliwienie jednemu z uczestników sterowania systemem kolejkowym i przesyłania bieżącego stanu do odległego uczestnika. Gdy nadejdzie kolej pająków, menedżer kolejki pozwala systemowi AI utworzyć polecenie, które jest wysyłane do użytkownika zdalnego. Ponieważ silnik gry działa tylko na podstawie poleceń i identyfikatorów obiektów, symulacja gry będzie taka sama po obu stronach. Wszystkie jednostki mogą też zawierać komponent AI, który umożliwia łatwe testowanie automatyczne.

Na początku rozwoju gry, gdy skupialiśmy się na logice gry, lepszym rozwiązaniem było użycie prostszego mechanizmu renderowania na płótnie. Prawdziwa zabawa rozpoczęła się jednak po wdrożeniu wersji 3D i ożywieniu scen za pomocą środowisk i animacji. Używamy three.js jako silnika 3D, a dzięki architekturze jego osiągnięcie jest bardzo łatwe.

Pozycja myszy jest wysyłana do użytkownika zdalnego częściej, a wskaźnik świetlny 3D daje subtelne wskazówki dotyczące bieżącej pozycji kursora.