Jak zbudować grę dla wielu użytkowników w czasie rzeczywistym od podstaw?
Opublikowany: 2022-03-10W miarę utrzymywania się pandemii, nagle zdalna drużyna, z którą pracuję, była coraz bardziej pozbawiona piłkarzyków. Myślałem o tym, jak grać w piłkarzyki w zdalnym otoczeniu, ale było jasne, że samo odtworzenie reguł gry w piłkarzyki na ekranie nie będzie zabawne.
Zabawne jest kopanie piłki za pomocą samochodzików — realizacja zrealizowana podczas zabawy z moim 2-letnim dzieckiem. Tej samej nocy postanowiłem zbudować pierwszy prototyp gry, która miała stać się Autowuzzlerem .
Pomysł jest prosty : gracze kierują wirtualnymi samochodzikami na odwróconej arenie, która przypomina stół do piłkarzyków. Pierwsza drużyna, która zdobędzie 10 bramek, wygrywa.
Oczywiście pomysł wykorzystania samochodów do gry w piłkę nożną nie jest wyjątkowy, ale dwie główne idee powinny wyróżniać Autowuzzler : chciałem zrekonstruować wygląd i sposób gry na fizycznym stole do piłkarzyków i chciałem się upewnić, że tak jest tak proste, jak to możliwe, aby zaprosić znajomych lub członków drużyny do szybkiej, casualowej gry.
W tym artykule opiszę proces tworzenia Autowuzzlera , wybrane przeze mnie narzędzia i frameworki, a także podzielę się kilkoma szczegółami implementacji i lekcjami, których się nauczyłem.
Pierwszy działający (okropny) prototyp
Pierwszy prototyp został zbudowany przy użyciu silnika gier o otwartym kodzie źródłowym, Phaser.js, głównie dla dołączonego silnika fizyki i ponieważ miałem już z nim pewne doświadczenie. Etap gry był osadzony w aplikacji Next.js, ponownie, ponieważ miałem już solidną wiedzę na temat Next.js i chciałem skupić się głównie na grze.
Ponieważ gra musi obsługiwać wielu graczy w czasie rzeczywistym , wykorzystałem Express jako brokera WebSockets. Tutaj jednak staje się to trudne.
Ponieważ obliczenia fizyczne zostały wykonane na kliencie w grze Phaser, wybrałem prostą, ale oczywiście błędną logikę: pierwszy podłączony klient miał wątpliwy przywilej wykonywania obliczeń fizycznych dla wszystkich obiektów gry, wysyłając wyniki do serwera ekspresowego, który z kolei transmitował zaktualizowane pozycje, kąty i siły z powrotem do klientów drugiego gracza. Inni klienci następnie zastosowaliby zmiany do obiektów gry.
Doprowadziło to do sytuacji, w której pierwszy gracz mógł zobaczyć, jak fizyka dzieje się w czasie rzeczywistym (w końcu dzieje się to lokalnie w jego przeglądarce), podczas gdy wszyscy pozostali gracze pozostawali w tyle o co najmniej 30 milisekund (wybrałem szybkość transmisji ) lub — jeśli połączenie sieciowe pierwszego gracza było wolne — znacznie gorzej.
Jeśli brzmi to dla ciebie jak kiepska architektura — masz absolutną rację. Jednak zaakceptowałem ten fakt, aby szybko uzyskać coś grywalnego, aby dowiedzieć się, czy gra jest naprawdę fajna .
Potwierdź pomysł, zrzuć prototyp
Choć implementacja była wadliwa, była wystarczająco grywalna, aby zaprosić znajomych na pierwszą jazdę próbną. Opinie były bardzo pozytywne , a głównym problemem była – co nie jest zaskakujące – wydajność w czasie rzeczywistym. Inne nieodłączne problemy to sytuacja, w której pierwszy gracz (pamiętaj, ten odpowiedzialny za wszystko ) opuścił grę — kto powinien przejąć? W tym momencie był tylko jeden pokój gier, więc każdy mógł dołączyć do tej samej gry. Byłem również nieco zaniepokojony rozmiarem pakietu wprowadzonej biblioteki Phaser.js.
Nadszedł czas, aby zrzucić prototyp i zacząć od nowej konfiguracji i jasnego celu.
Konfiguracja projektu
Najwyraźniej podejście „pierwszy klient rządzi wszystkim” należało zastąpić rozwiązaniem, w którym stan gry znajduje się na serwerze . W moich badaniach natknąłem się na Colyseusa, który brzmiał jak idealne narzędzie do pracy.
Do pozostałych głównych elementów konstrukcyjnych gry wybrałem:
- Matter.js jako silnik fizyki zamiast Phaser.js, ponieważ działa w Node, a Autowuzzler nie wymaga pełnej struktury gry.
- SvelteKit jako framework aplikacji zamiast Next.js, ponieważ w tym czasie właśnie wszedł do publicznej wersji beta. (Poza tym uwielbiam pracować ze Svelte.)
- Supabase.io do przechowywania stworzonych przez użytkowników kodów PIN do gier.
Przyjrzyjmy się tym cegiełkom bardziej szczegółowo.
Zsynchronizowany, scentralizowany stan gry z Colyseus
Colyseus to framework do gier wieloosobowych oparty na Node.js i Express. W swej istocie zapewnia:
- Synchronizacja stanu między klientami w autorytatywny sposób;
- Efektywna komunikacja w czasie rzeczywistym za pomocą WebSockets poprzez wysyłanie tylko zmienionych danych;
- Konfiguracje wielopokojowe;
- Biblioteki klienckie dla JavaScript, Unity, Defold Engine, Haxe, Cocos Creator, Construct3;
- Hooki cyklu życia, np. tworzony pokój, dołączanie użytkowników, opuszczanie użytkowników i inne;
- Wysyłanie wiadomości jako wiadomości rozgłoszeniowych do wszystkich użytkowników w pokoju lub do jednego użytkownika;
- Wbudowany panel monitorujący i narzędzie do testowania obciążenia.
Uwaga : Dokumentacja Colyseus ułatwia rozpoczęcie pracy z serwerem typu barebone Colyseus, udostępniając skrypt npm init
i repozytorium przykładów.
Tworzenie schematu
Główną jednostką aplikacji Colyseus jest pokój gier, w którym znajduje się stan dla instancji pojedynczego pokoju i wszystkich jej obiektów gry. W przypadku Autowuzzlera jest to sesja gry z:
- dwa zespoły,
- skończona ilość graczy,
- jedna piłka.
Należy zdefiniować schemat dla wszystkich właściwości obiektów gry, które powinny być synchronizowane między klientami . Na przykład chcemy, aby piłka się synchronizowała, dlatego musimy stworzyć schemat dla piłki:
class Ball extends Schema { constructor() { super(); this.x = 0; this.y = 0; this.angle = 0; this.velocityX = 0; this.velocityY = 0; } } defineTypes(Ball, { x: "number", y: "number", angle: "number", velocityX: "number", velocityY: "number" });
W powyższym przykładzie tworzona jest nowa klasa, która rozszerza klasę schematu dostarczoną przez Colyseus; w konstruktorze wszystkie właściwości otrzymują wartość początkową. Położenie i ruch piłki opisano za pomocą pięciu właściwości: x
, y
, angle
, velocityX,
velocityY
. Dodatkowo musimy określić typy każdej właściwości . W tym przykładzie użyto składni JavaScript, ale można również użyć nieco bardziej zwartej składni TypeScript.
Typy właściwości mogą być typami pierwotnymi:
-
string
-
boolean
-
number
(a także bardziej wydajne typy całkowite i zmiennoprzecinkowe)
lub złożone typy:
-
ArraySchema
(podobny do Array w JavaScript) -
MapSchema
(podobny do Map w JavaScript) -
SetSchema
(podobny do Set w JavaScript) -
CollectionSchema
(podobny do ArraySchema, ale bez kontroli nad indeksami)
Powyższa klasa Ball
ma pięć właściwości typu number
: współrzędne ( x
, y
), bieżący angle
i wektor prędkości ( velocityX
, velocityY
).
Schemat dla graczy jest podobny, ale zawiera kilka dodatkowych właściwości do przechowywania nazwy gracza i numeru drużyny, które należy podać podczas tworzenia instancji gracza:
class Player extends Schema { constructor(teamNumber) { super(); this.name = ""; this.x = 0; this.y = 0; this.angle = 0; this.velocityX = 0; this.velocityY = 0; this.teamNumber = teamNumber; } } defineTypes(Player, { name: "string", x: "number", y: "number", angle: "number", velocityX: "number", velocityY: "number", angularVelocity: "number", teamNumber: "number", });
Wreszcie schemat dla Room
Autowuzzler łączy wcześniej zdefiniowane klasy: Jedna instancja pokoju ma wiele zespołów (przechowywanych w ArraySchema). Zawiera również pojedynczą kulę, dlatego tworzymy nową instancję Ball w konstruktorze RoomSchema. Gracze są przechowywani w MapSchema do szybkiego wyszukiwania przy użyciu ich identyfikatorów.
Konfiguracja z wieloma pokojami („Dopasowywanie”)
Każdy może dołączyć do gry Autowuzzler , jeśli ma ważny PIN do gry. Nasz serwer Colyseus tworzy nową instancję pokoju dla każdej sesji gry, gdy tylko pierwszy gracz dołączy i odrzuca pokój, gdy ostatni gracz go opuści.
Proces przypisywania graczy do wybranego pokoju gry nazywa się „dobieraniem meczów”. Colyseus bardzo ułatwia konfigurację dzięki zastosowaniu metody filterBy
podczas definiowania nowego pomieszczenia:
gameServer.define("autowuzzler", AutowuzzlerRoom).filterBy(['gamePIN']);
Teraz wszyscy gracze dołączający do gry za pomocą tego samego gamePIN
(później zobaczymy, jak „dołączyć”) trafią do tego samego pokoju gier! Wszelkie aktualizacje stanu i inne transmisje są ograniczone do graczy w tym samym pokoju.
Fizyka w aplikacji Colyseus
Colyseus zapewnia wiele nieszablonowych możliwości szybkiego uruchomienia i działania z autorytatywnym serwerem gier, ale pozostawia deweloperowi stworzenie rzeczywistej mechaniki gry — w tym fizyki. Phaser.js, którego użyłem w prototypie, nie może być uruchomiony w środowisku innym niż przeglądarka, ale zintegrowany silnik fizyczny Phaser.js, Matter.js, może działać na Node.js.
Dzięki Matter.js definiujesz świat fizyki o pewnych właściwościach fizycznych, takich jak jego rozmiar i grawitacja. Zapewnia kilka metod tworzenia prymitywnych obiektów fizycznych, które oddziałują ze sobą poprzez przestrzeganie (symulowanych) praw fizyki, w tym masy, zderzeń, ruchu z tarciem i tak dalej. Możesz przesuwać obiekty, stosując siłę — tak jak w prawdziwym świecie.
„Świat” Matter.js jest sercem gry Autowuzzler ; określa, jak szybko poruszają się samochody, jak odbija się piłka, gdzie znajdują się bramki i co się stanie, jeśli ktoś strzeli bramkę.
let ball = Bodies.circle( ballInitialXPosition, ballInitialYPosition, radius, { render: { sprite: { texture: '/assets/ball.png', } }, friction: 0.002, restitution: 0.8 } ); World.add(this.engine.world, [ball]);
Uproszczony kod dodawania obiektu gry „piłka” do sceny w Matter.js.
Po zdefiniowaniu reguł, Matter.js może działać z lub bez faktycznego renderowania czegoś na ekranie. W przypadku Autowuzzlera wykorzystuję tę funkcję do ponownego wykorzystania kodu świata fizyki zarówno dla serwera, jak i klienta — z kilkoma kluczowymi różnicami:
Świat fizyki na serwerze :
- odbiera dane wejściowe użytkownika (zdarzenia klawiatury w celu sterowania samochodem) za pośrednictwem Colyseusa i stosuje odpowiednią siłę na obiekcie gry (samochodzie użytkownika);
- wykonuje wszystkie obliczenia fizyczne dla wszystkich obiektów (graczy i piłki), w tym wykrywa kolizje;
- przekazuje zaktualizowany stan każdego obiektu gry z powrotem do Colyseusa, który z kolei przesyła go do klientów;
- jest aktualizowany co 16,6 milisekundy (= 60 klatek na sekundę), wyzwalany przez nasz serwer Colyseus.
Świat fizyki na kliencie :
- nie manipuluje bezpośrednio obiektami gry;
- otrzymuje zaktualizowany stan dla każdego obiektu gry od Colyseusa;
- stosuje zmiany pozycji, prędkości i kąta po otrzymaniu zaktualizowanego stanu;
- wysyła dane wejściowe użytkownika (zdarzenia klawiatury do sterowania samochodem) do Colyseusa;
- ładuje ikonki gry i używa renderera, aby narysować świat fizyki na elemencie canvas;
- pomija wykrywanie kolizji (używając opcji
isSensor
dla obiektów); - aktualizacje za pomocą requestAnimationFrame, najlepiej przy 60 fps.
Teraz, gdy cała magia dzieje się na serwerze, klient obsługuje tylko dane wejściowe i rysuje stan, który otrzymuje z serwera na ekran. Z jednym wyjątkiem:
Interpolacja na kliencie
Ponieważ ponownie używamy tego samego świata fizyki Matter.js na kliencie, możemy poprawić doświadczoną wydajność za pomocą prostej sztuczki. Zamiast tylko aktualizować pozycję obiektu gry, synchronizujemy również prędkość obiektu . W ten sposób obiekt nadal porusza się po swojej trajektorii, nawet jeśli kolejna aktualizacja z serwera trwa dłużej niż zwykle. Więc zamiast przesuwać obiekty dyskretnymi krokami z pozycji A do pozycji B, zmieniamy ich pozycję i sprawiamy, że poruszają się w określonym kierunku.
Koło życia
Klasa Autowuzzler Room
to miejsce, w którym obsługiwana jest logika związana z różnymi fazami pokoju Colyseus. Colyseus udostępnia kilka metod cyklu życia:
-
onCreate
: kiedy tworzony jest nowy pokój (zwykle kiedy pierwszy klient się połączy); -
onAuth
: jako hak autoryzacji, aby zezwolić lub odmówić wejścia do pokoju; -
onJoin
: kiedy klient łączy się z pokojem; -
onLeave
: kiedy klient odłącza się od pokoju; -
onDispose
: kiedy pomieszczenie jest wyrzucane.
Pokój Autowuzzler tworzy nową instancję świata fizyki (patrz rozdział „Fizyka w aplikacji Colyseus”) zaraz po jej utworzeniu ( onCreate
) i dodaje gracza do świata, gdy klient się połączy ( onJoin
). Następnie aktualizuje świat fizyki 60 razy na sekundę (co 16,6 milisekund) przy użyciu metody setSimulationInterval
(nasza główna pętla gry):
// deltaTime is roughly 16.6 milliseconds this.setSimulationInterval((deltaTime) => this.world.updateWorld(deltaTime));
Obiekty fizyczne są niezależne od obiektów Colyseus, co pozostawia nam dwie permutacje tego samego obiektu gry (takiego jak piłka), tj. obiekt w świecie fizyki i obiekt Colyseus, który można zsynchronizować.
Gdy tylko obiekt fizyczny ulegnie zmianie, jego zaktualizowane właściwości należy zastosować z powrotem do obiektu Colyseus. Możemy to osiągnąć, słuchając zdarzenia afterUpdate
i ustawiając z niego wartości:
Events.on(this.engine, "afterUpdate", () => { // apply the x position of the physics ball object back to the colyseus ball object this.state.ball.x = this.physicsWorld.ball.position.x; // ... all other ball properties // loop over all physics players and apply their properties back to colyseus players objects })
Jest jeszcze jedna kopia obiektów, którymi musimy się zająć: obiekty gry w grze skierowanej do użytkownika .
Aplikacja po stronie klienta
Teraz, gdy mamy na serwerze aplikację, która zajmuje się synchronizacją stanu gry dla wielu pomieszczeń oraz obliczeniami fizyki, skupmy się na budowie strony internetowej i rzeczywistego interfejsu gry . Frontend Autowuzzler ma następujące obowiązki:
- umożliwia użytkownikom tworzenie i udostępnianie kodów PIN do gier w celu uzyskania dostępu do poszczególnych pokoi;
- wysyła utworzone kody PIN gry do bazy danych Supabase w celu utrwalenia;
- udostępnia opcjonalną stronę „Dołącz do gry”, na której gracze mogą wpisać kod PIN do gry;
- weryfikuje kody PIN gry, gdy gracz dołącza do gry;
- hostuje i renderuje faktyczną grę pod udostępnianym (tj. unikalnym) adresem URL;
- łączy się z serwerem Colyseus i obsługuje aktualizacje stanu;
- udostępnia stronę docelową („marketingową”).
Do realizacji tych zadań wybrałem SvelteKit zamiast Next.js z następujących powodów:
Dlaczego SvelteKit?
Odkąd zbudowałem neolightsout, chciałem stworzyć kolejną aplikację przy użyciu Svelte. Kiedy SvelteKit (oficjalny framework aplikacji dla Svelte) wszedł do publicznej wersji beta, postanowiłem zbudować z nim Autowuzzlera i zaakceptować wszelkie bóle głowy związane z używaniem nowej wersji beta — radość z używania Svelte wyraźnie to rekompensuje.
Te kluczowe cechy sprawiły, że wybrałem SvelteKit zamiast Next.js do faktycznej implementacji frontendu gry:
- Svelte jest strukturą interfejsu użytkownika i kompilatorem, dlatego dostarcza minimalny kod bez środowiska uruchomieniowego klienta;
- Svelte ma ekspresyjny język szablonów i system komponentów (osobiste preferencje);
- Svelte zawiera globalne sklepy, przejścia i animacje po wyjęciu z pudełka, co oznacza: brak zmęczenia decyzjami wybierając zestaw narzędzi do globalnego zarządzania stanem i bibliotekę animacji;
- Svelte obsługuje CSS w zakresie w komponentach jednoplikowych;
- SvelteKit obsługuje SSR, prosty, ale elastyczny routing oparty na plikach i trasy po stronie serwera do budowania interfejsu API;
- SvelteKit umożliwia każdej stronie wykonanie kodu na serwerze, np. pobranie danych, które są używane do renderowania strony;
- Układy współdzielone na trasach;
- SvelteKit można uruchomić w środowisku bezserwerowym.
Tworzenie i przechowywanie kodów PIN do gier
Zanim użytkownik będzie mógł rozpocząć grę, musi najpierw utworzyć kod PIN do gry. Dzieląc się kodem PIN z innymi, wszyscy mogą uzyskać dostęp do tego samego pokoju gier.
Jest to świetny przypadek użycia punktów końcowych po stronie serwera SvelteKits w połączeniu z funkcją Sveltes onMount: punkt końcowy /api/createcode
generuje kod PIN gry, przechowuje go w bazie danych Supabase.io i wyświetla kod PIN gry jako odpowiedź . Ta odpowiedź jest pobierana, gdy tylko składnik strony „create” zostanie zamontowany:
Przechowywanie kodów PIN do gier za pomocą Supabase.io
Supabase.io to alternatywa typu open source dla Firebase. Supabase bardzo ułatwia tworzenie bazy danych PostgreSQL i dostęp do niej za pośrednictwem jednej z bibliotek klienckich lub przez REST.
Dla klienta JavaScript importujemy funkcję createClient
i wykonujemy ją za pomocą parametrów supabase_url
i supabase_key
, które otrzymaliśmy przy tworzeniu bazy danych. Aby przechowywać kod PIN gry , który jest tworzony przy każdym wywołaniu punktu końcowego createcode
, wystarczy uruchomić to proste zapytanie insert
:
import { createClient } from '@supabase/supabase-js' const database = createClient( import.meta.env.VITE_SUPABASE_URL, import.meta.env.VITE_SUPABASE_KEY ); const { data, error } = await database .from("games") .insert([{ code: 123456 }]);
Uwaga : supabase_url
i supabase_key
są przechowywane w pliku .env. Ze względu na Vite — narzędzie do budowania w sercu SvelteKit — wymagane jest dodanie do zmiennych środowiskowych prefiksu VITE_, aby były dostępne w SvelteKit.
Dostęp do gry
Chciałem, aby dołączenie do gry Autowuzzler było tak proste, jak kliknięcie linku. Dlatego każdy pokój gier musiał mieć własny adres URL oparty na wcześniej utworzonym PIN-ie do gry , np. https://autowuzzler.com/play/12345.
W SvelteKit strony z dynamicznymi parametrami trasy są tworzone przez umieszczenie dynamicznych części trasy w nawiasach kwadratowych podczas nazywania pliku strony: client/src/routes/play/[gamePIN].svelte
. Wartość parametru gamePIN
stanie się wtedy dostępna w komponencie strony (szczegółowe informacje można znaleźć w dokumentacji SvelteKit). Na ścieżce play
musimy połączyć się z serwerem Colyseus, utworzyć instancję świata fizyki w celu renderowania na ekranie, obsługiwać aktualizacje obiektów gry, słuchać danych wejściowych z klawiatury i wyświetlać inne interfejsy użytkownika, takie jak wynik, i tak dalej.
Łączenie się z Colyseus i aktualizowanie stanu
Biblioteka klienta Colyseus umożliwia nam podłączenie klienta do serwera Colyseus. Najpierw utwórzmy nowy Colyseus.Client
, wskazując go na serwer Colyseus ( ws://localhost:2567
w fazie rozwoju). Następnie dołączamy do pokoju o nazwie, którą wybraliśmy wcześniej ( autowuzzler
) i gamePIN
z parametru trasy. Parametr gamePIN
zapewnia, że użytkownik dołącza do właściwej instancji pokoju (patrz „Dobieranie meczów” powyżej).
let client = new Colyseus.Client("ws://localhost:2567"); this.room = await client.joinOrCreate("autowuzzler", { gamePIN });
Ponieważ SvelteKit początkowo renderuje strony na serwerze, musimy upewnić się, że ten kod działa na kliencie dopiero po zakończeniu ładowania strony. Ponownie używamy funkcji cyklu życia onMount
dla tego przypadku użycia. (Jeśli znasz React, onMount
jest podobny do haka useEffect
z pustą tablicą zależności.)
onMount(async () => { let client = new Colyseus.Client("ws://localhost:2567"); this.room = await client.joinOrCreate("autowuzzler", { gamePIN }); })
Teraz, gdy jesteśmy połączeni z serwerem gry Colyseus, możemy zacząć słuchać wszelkich zmian w naszych obiektach gry.
Oto przykład, jak słuchać gracza dołączającego do pokoju ( onAdd
) i otrzymywania kolejnych aktualizacji stanu tego gracza:
this.room.state.players.onAdd = (player, key) => { console.log(`Player has been added with sessionId: ${key}`); // add player entity to the game world this.world.createPlayer(key, player.teamNumber); // listen for changes to this player player.onChange = (changes) => { changes.forEach(({ field, value }) => { this.world.updatePlayer(key, field, value); // see below }); }; };
W metodzie updatePlayer
świata fizyki aktualizujemy właściwości jeden po drugim, ponieważ onChange onChange
dostarcza zestaw wszystkich zmienionych właściwości.
Uwaga : ta funkcja działa tylko w wersji klienckiej świata fizyki, ponieważ obiektami gry manipuluje się tylko pośrednio przez serwer Colyseus.
updatePlayer(sessionId, field, value) { // get the player physics object by its sessionId let player = this.world.players.get(sessionId); // exit if not found if (!player) return; // apply changes to the properties switch (field) { case "angle": Body.setAngle(player, value); break; case "x": Body.setPosition(player, { x: value, y: player.position.y }); break; case "y": Body.setPosition(player, { x: player.position.x, y: value }); break; // set velocityX, velocityY, angularVelocity ... } }
Ta sama procedura dotyczy innych obiektów gry (piłki i drużyn): posłuchaj ich zmian i zastosuj zmienione wartości do świata fizyki klienta.
Jak dotąd żadne obiekty się nie poruszają, ponieważ nadal musimy nasłuchiwać danych wprowadzanych z klawiatury i wysyłać je na serwer . Zamiast bezpośrednio wysyłać zdarzenia przy każdym zdarzeniu naciśnięcia klawisza, utrzymujemy mapę aktualnie naciśniętych klawiszy i wysyłamy zdarzenia do serwera keydown
w pętli 50ms. W ten sposób możemy wspierać naciskanie wielu klawiszy jednocześnie i złagodzić pauzę, która ma miejsce po pierwszym i kolejnych keydown
naciśnięcia klawisza, gdy klawisz pozostaje wciśnięty:
let keys = {}; const keyDown = e => { keys[e.key] = true; }; const keyUp = e => { keys[e.key] = false; }; document.addEventListener('keydown', keyDown); document.addEventListener('keyup', keyUp); let loop = () => { if (keys["ArrowLeft"]) { this.room.send("move", { direction: "left" }); } else if (keys["ArrowRight"]) { this.room.send("move", { direction: "right" }); } if (keys["ArrowUp"]) { this.room.send("move", { direction: "up" }); } else if (keys["ArrowDown"]) { this.room.send("move", { direction: "down" }); } // next iteration requestAnimationFrame(() => { setTimeout(loop, 50); }); } // start loop setTimeout(loop, 50);
Teraz cykl jest zakończony: nasłuchuj naciśnięć klawiszy, wyślij odpowiednie polecenia do serwera Colyseus, aby manipulować światem fizyki na serwerze. Serwer Colyseus następnie stosuje nowe właściwości fizyczne do wszystkich obiektów gry i propaguje dane z powrotem do klienta, aby zaktualizować instancję gry dostępną dla użytkownika.
Drobne niedogodności
Z perspektywy czasu przychodzą mi do głowy dwie rzeczy z kategorii nikt-nikt-mi-mówił-ale-ktoś-powinien :
- Dobre zrozumienie działania silników fizycznych jest korzystne. Spędziłem sporo czasu na dostrajaniu właściwości fizycznych i ograniczeń. Mimo że wcześniej zbudowałem małą grę za pomocą Phaser.js i Matter.js, było wiele prób i błędów, aby obiekty poruszały się w taki sposób, w jaki je sobie wyobrażałem.
- Czas rzeczywisty jest trudny — zwłaszcza w grach opartych na fizyce. Drobne opóźnienia znacznie pogarszają jakość obsługi i chociaż synchronizacja stanu między klientami z Colyseusem działa świetnie, nie może usunąć opóźnień w obliczeniach i transmisji.
Gotcha i zastrzeżenia z SvelteKit
Ponieważ używałem SvelteKit, gdy był świeżo po wyjęciu z piekarnika beta, było kilka błędów i zastrzeżeń, o których chciałbym wspomnieć:
- Zajęło trochę czasu, zanim zorientowaliśmy się, że zmienne środowiskowe muszą być poprzedzone prefiksem VITE_, aby można było ich używać w SvelteKit. Jest to teraz właściwie udokumentowane w FAQ.
- Aby korzystać z Supabase, musiałem dodać Supabase zarówno do listy
dependencies
, jak idevDependencies
pakietu package.json. Uważam, że już tak nie jest. - Funkcja
load
SvelteKits działa zarówno na serwerze, jak i na kliencie! - Aby umożliwić pełną wymianę modułu na gorąco (w tym zachowanie stanu), musisz ręcznie dodać wiersz komentarza
<!-- @hmr:keep-all -->
w komponentach strony. Zobacz FAQ, aby uzyskać więcej informacji.
Wiele innych frameworków też by pasowało, ale nie żałuję, że wybrałem SvelteKit do tego projektu. Umożliwiło mi to bardzo wydajną pracę nad aplikacją kliencką — głównie dlatego, że samo Svelte jest bardzo ekspresyjne i pomija wiele standardowych kodów, ale także dlatego, że Svelte ma takie rzeczy, jak animacje, przejścia, CSS z zakresem i magazyny globalne. SvelteKit dostarczył wszystkie potrzebne elementy (SSR, routing, trasy serwerów) i chociaż wciąż jest w wersji beta, czuł się bardzo stabilnie i szybko.
Wdrożenie i hosting
Początkowo hostowałem serwer Colyseus (Node) na instancji Heroku i zmarnowałem dużo czasu na uruchamianie WebSockets i CORS. Jak się okazuje, wydajność maleńkiej (darmowej) hamowni Heroku nie jest wystarczająca do użycia w czasie rzeczywistym. Później przeprowadziłem migrację aplikacji Colyseus na mały serwer w Linode. Aplikacja po stronie klienta jest wdrażana i hostowana w Netlify za pośrednictwem SvelteKits adapter-netlify. Żadnych niespodzianek: Netlify po prostu działał świetnie!
Wniosek
Rozpoczęcie od naprawdę prostego prototypu, aby zweryfikować pomysł, bardzo mi pomogło w ustaleniu, czy warto podążać za projektem i gdzie leżą techniczne wyzwania gry. W końcowej implementacji Colyseus zajął się wszystkimi ciężkimi zadaniami związanymi z synchronizacją stanu w czasie rzeczywistym na wielu klientach, rozproszonych w wielu pokojach. To imponujące, jak szybko można zbudować aplikację dla wielu użytkowników w czasie rzeczywistym za pomocą Colyseusa — gdy tylko zorientujesz się, jak prawidłowo opisać schemat. Wbudowany panel monitorowania Colyseus pomaga w rozwiązywaniu problemów z synchronizacją.
To, co skomplikowało tę konfigurację, to warstwa fizyki gry, ponieważ wprowadziła dodatkową kopię każdego obiektu gry związanego z fizyką, który wymagał konserwacji. Przechowywanie kodów PIN do gier w Supabase.io z aplikacji SvelteKit było bardzo proste. Z perspektywy czasu mogłem po prostu użyć bazy danych SQLite do przechowywania kodów PIN gry, ale wypróbowywanie nowych rzeczy to połowa zabawy podczas tworzenia pobocznych projektów.
Wreszcie, użycie SvelteKit do zbudowania frontendu gry pozwoliło mi na szybkie poruszanie się — i od czasu do czasu z uśmiechem radości na mojej twarzy.
Teraz śmiało zaproś znajomych na rundę Autowuzzlera!
Dalsza lektura na Smashing Magazine
- „Zacznij od React, budując grę Whac-A-Mole”, Jhey Tompkins
- „Jak zbudować wieloosobową grę w wirtualnej rzeczywistości w czasie rzeczywistym”, Alvin Wan
- „Pisanie wieloosobowego silnika tekstowego przygodowego w Node.js”, Fernando Doglio
- „Przyszłość projektowania witryn mobilnych: projektowanie gier wideo i opowiadanie historii”, Suzanne Scacca
- „Jak zbudować niekończącą się grę biegacza w wirtualnej rzeczywistości”, Alvin Wan