Cum să construiți un joc cu mai mulți utilizatori în timp real de la zero

Publicat: 2022-03-10
Rezumat rapid ↬ Acest articol evidențiază procesul, deciziile tehnice și lecțiile învățate din spatele construirii jocului în timp real Autowuzzler. Aflați cum să partajați starea jocului între mai mulți clienți în timp real cu Colyseus, să faceți calcule fizice cu Matter.js, să stocați date în Supabase.io și să construiți front-end-ul cu SvelteKit.

Pe măsură ce pandemia a persistat, echipa brusc îndepărtată cu care lucrez a devenit din ce în ce mai lipsită de fotbal. M-am gândit cum să joc fotbal la distanță, dar era clar că simpla reconstrucție a regulilor fotbalului pe un ecran nu ar fi foarte distractiv.

Ceea ce este distractiv este să lovi cu piciorul într-o minge folosind mașini de jucărie - o realizare făcută în timp ce mă jucam cu copilul meu de 2 ani. În aceeași noapte mi-am propus să construiesc primul prototip pentru un joc care avea să devină Autowuzzler .

Ideea este simplă : jucătorii conduc mașini de jucărie virtuale într-o arenă de sus în jos, care seamănă cu o masă de fotbal. Prima echipă care înscrie 10 goluri câștigă.

Desigur, ideea de a folosi mașini pentru a juca fotbal nu este unică, dar două idei principale ar trebui să deosebească Autowuzzler : am vrut să reconstruiesc o parte din aspectul și senzația de a juca pe o masă fizică de football și am vrut să mă asigur că este cât mai ușor de invitat prietenii sau colegii de echipă la un joc casual rapid.

În acest articol, voi descrie procesul din spatele creării Autowuzzler , instrumentele și cadrele pe care le-am ales și voi împărtăși câteva detalii de implementare și lecții pe care le-am învățat.

Interfața cu utilizatorul jocului care arată un fundal de la o masă de foosball, șase mașini în două echipe și o minge.
Autowuzzler (beta) cu șase jucători concurenți în două echipe. (Previzualizare mare)

Primul prototip de lucru (îngrozitor).

Primul prototip a fost construit folosind motorul de joc open-source Phaser.js, mai ales pentru motorul de fizică inclus și pentru că aveam deja ceva experiență cu el. Etapa jocului a fost încorporată într-o aplicație Next.js, din nou pentru că aveam deja o înțelegere solidă a Next.js și doream să mă concentrez în principal pe joc.

Deoarece jocul trebuie să accepte mai mulți jucători în timp real , am folosit Express ca broker WebSockets. Totuși, aici devine dificil.

Deoarece calculele de fizică au fost făcute pe client în jocul Phaser, am ales o logică simplă, dar evident defectuoasă: primul client conectat a avut privilegiul îndoielnic de a face calculele de fizică pentru toate obiectele jocului, trimițând rezultatele către serverul expres, care, la rândul său, a transmis pozițiile, unghiurile și forțele actualizate înapoi clienților celuilalt jucător. Ceilalți clienți ar aplica apoi modificările obiectelor de joc.

Acest lucru a dus la situația în care primul jucător a ajuns să vadă fizica care se întâmplă în timp real (se întâmplă local în browserul lor, până la urmă), în timp ce toți ceilalți jucători au rămas în urmă cu cel puțin 30 de milisecunde (rata de difuzare pe care am ales-o ), sau — dacă conexiunea la rețea a primului jucător a fost lentă — considerabil mai rău.

Dacă ți se pare o arhitectură slabă, ai perfectă dreptate. Cu toate acestea, am acceptat acest fapt în favoarea obținerii rapid a ceva jucabil pentru a afla dacă jocul este într-adevăr distractiv de jucat.

Validați ideea, aruncați prototipul

Oricât de defectuoasă a fost implementarea, era suficient de redabilă pentru a invita prietenii la un prim test drive. Feedback-ul a fost foarte pozitiv , preocuparea majoră fiind – deloc surprinzător – performanța în timp real. Alte probleme inerente au inclus situația în care primul jucător (amintiți-vă, cel care se ocupă de tot ) a părăsit jocul - cine ar trebui să preia conducerea? În acest moment, exista o singură sală de jocuri, așa că oricine se alătură aceluiași joc. Am fost, de asemenea, puțin îngrijorat de dimensiunea pachetului introdusă de biblioteca Phaser.js.

Era timpul să aruncăm prototipul și să începem cu o configurație nouă și un obiectiv clar.

Configurarea proiectului

În mod clar, abordarea „primul client guvernează pe toate” trebuia înlocuită cu o soluție în care starea jocului se află pe server . În cercetarea mea, am dat peste Coliseu, care suna ca instrumentul perfect pentru muncă.

Pentru celelalte blocuri principale ale jocului am ales:

  • Matter.js ca motor de fizică în loc de Phaser.js, deoarece rulează în Node și Autowuzzler nu necesită un cadru de joc complet.
  • SvelteKit ca cadru de aplicație în loc de Next.js, deoarece tocmai a intrat în versiunea beta publică la acel moment. (În plus: îmi place să lucrez cu Svelte.)
  • Supabase.io pentru stocarea codurilor PIN pentru jocuri create de utilizator.

Să ne uităm la acele blocuri de construcție mai detaliat.

Mai multe după săritură! Continuați să citiți mai jos ↓

Stare de joc sincronizată, centralizată cu Coliseu

Colyseus este un cadru de joc multiplayer bazat pe Node.js și Express. În esență, acesta oferă:

  • Sincronizarea stării între clienți într-un mod autorizat;
  • Comunicare eficientă în timp real folosind WebSockets prin trimiterea numai a datelor modificate;
  • Configurații cu mai multe camere;
  • Biblioteci client pentru JavaScript, Unity, Defold Engine, Haxe, Cocos Creator, Construct3;
  • Cârlige pentru ciclul de viață, de exemplu, se creează o cameră, se alătură utilizatorilor, se părăsește utilizatorul și multe altele;
  • Trimiterea de mesaje, fie ca mesaje difuzate către toți utilizatorii din cameră, fie către un singur utilizator;
  • Un panou de monitorizare încorporat și un instrument de testare a sarcinii.

Notă : documentele Colyseus facilitează începerea cu un server Barebones Colyseus, oferind un script de npm init și un depozit de exemple.

Crearea unei scheme

Entitatea principală a unei aplicații Colyseus este camera de joc, care deține starea pentru o singură instanță de cameră și toate obiectele sale de joc. În cazul Autowuzzler , este o sesiune de joc cu:

  • doua echipe,
  • un număr finit de jucători,
  • o minge.

Trebuie definită o schemă pentru toate proprietățile obiectelor de joc care ar trebui să fie sincronizate între clienți . De exemplu, dorim ca mingea să se sincronizeze și, prin urmare, trebuie să creăm o schemă pentru minge:

 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" });

În exemplul de mai sus, este creată o nouă clasă care extinde clasa schema oferită de Coliseu; în constructor, toate proprietățile primesc o valoare inițială. Poziția și mișcarea mingii sunt descrise folosind cele cinci proprietăți: x , y , angle , velocityX, velocityY . În plus, trebuie să specificăm tipurile fiecărei proprietăți . Acest exemplu folosește sintaxa JavaScript, dar puteți folosi și sintaxa TypeScript ceva mai compactă.

Tipurile de proprietăți pot fi fie tipuri primitive:

  • string
  • boolean
  • number (precum și tipuri mai eficiente de numere întregi și flotante)

sau tipuri complexe:

  • ArraySchema (similar cu Array în JavaScript)
  • MapSchema (similar cu Map în JavaScript)
  • SetSchema (similar cu Set in JavaScript)
  • CollectionSchema (similar cu ArraySchema, dar fără control asupra indecșilor)

Clasa Ball de mai sus are cinci proprietăți ale number de tip: coordonatele sale ( x , y ), angle său curent și vectorul viteză ( velocityX , velocityY ).

Schema pentru jucători este similară, dar include câteva proprietăți suplimentare pentru a stoca numele jucătorului și numărul echipei, care trebuie furnizate la crearea unei instanțe de jucător:

 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", });

În cele din urmă, schema pentru Autowuzzler Room conectează clasele definite anterior: O instanță de cameră are mai multe echipe (stocate într-un ArraySchema). De asemenea, conține o singură bilă, prin urmare creăm o nouă instanță Ball în constructorul RoomSchema. Jucătorii sunt stocați într-o MapSchema pentru recuperare rapidă folosind ID-urile lor.

 class RoomSchema extends Schema { constructor() { super(); this.teams = new ArraySchema(); this.ball = new Ball(); this.players = new MapSchema(); } } defineTypes(RoomSchema, { teams: [Team], // an Array of Team ball: Ball, // a single Ball instance players: { map: Player } // a Map of Players });
Notă : Definiția clasei Team este omisă.

Configurare cu mai multe camere („Match-Making”)

Oricine se poate alătura unui joc Autowuzzler dacă are un PIN valid pentru joc. Serverul nostru Colyseus creează o nouă instanță de cameră pentru fiecare sesiune de joc de îndată ce primul jucător se alătură și renunță la sala când ultimul jucător o părăsește.

Procesul de atribuire a jucătorilor la sala de joc dorită se numește „match-making”. Colyseus ușurează configurarea utilizând metoda filterBy atunci când definiți o cameră nouă:

 gameServer.define("autowuzzler", AutowuzzlerRoom).filterBy(['gamePIN']);

Acum, toți jucătorii care se alătură jocului cu același gamePIN de joc (vom vedea cum să se „alăture” mai târziu) vor ajunge în aceeași sală de joc! Orice actualizări de stat și alte mesaje difuzate sunt limitate la jucătorii din aceeași cameră.

Fizica într-o aplicație Coliseu

Colyseus oferă o mulțime de ieșite din cutie pentru a începe și rula rapid cu un server de joc autoritar, dar lasă la latitudinea dezvoltatorului să creeze mecanica reală a jocului - inclusiv fizica. Phaser.js, pe care l-am folosit în prototip, nu poate fi executat într-un mediu non-browser, dar motorul de fizică integrat al lui Phaser.js Matter.js poate rula pe Node.js.

Cu Matter.js, definiți o lume fizică cu anumite proprietăți fizice, cum ar fi dimensiunea și gravitația. Oferă mai multe metode pentru a crea obiecte fizice primitive care interacționează între ele respectând legile (simulate) ale fizicii, inclusiv masa, coliziunile, mișcarea cu frecare și așa mai departe. Puteți muta obiecte prin aplicarea forței , la fel cum ați face în lumea reală.

O „lume” Matter.js se află în centrul jocului Autowuzzler ; definește cât de repede se mișcă mașinile, cât de elastică ar trebui să fie mingea, unde sunt amplasate golurile și ce se întâmplă dacă cineva șutează un gol.

 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]);

Cod simplificat pentru adăugarea unui obiect de joc „minge” pe scena din Matter.js.

Odată ce regulile sunt definite, Matter.js poate rula cu sau fără redarea efectivă a ceva pe un ecran. Pentru Autowuzzler , folosesc această caracteristică pentru a reutiliza codul lumii fizice atât pentru server, cât și pentru client - cu câteva diferențe cheie:

Lumea fizicii pe server :

  • primește intrarea utilizatorului (evenimente de la tastatură pentru conducerea unei mașini) prin Coliseu și aplică forța corespunzătoare asupra obiectului de joc (mașina utilizatorului);
  • face toate calculele fizice pentru toate obiectele (jucători și mingea), inclusiv detectarea coliziunilor;
  • comunică starea actualizată pentru fiecare obiect de joc înapoi lui Coliseu, care la rândul său o transmite clienților;
  • este actualizat la fiecare 16,6 milisecunde (= 60 de cadre pe secundă), declanșat de serverul nostru Colyseus.

Lumea fizicii pe client :

  • nu manipulează direct obiectele jocului;
  • primește starea actualizată pentru fiecare obiect de joc de la Coliseu;
  • aplică modificări de poziție, viteză și unghi după primirea stării actualizate;
  • trimite intrarea utilizatorului (evenimente de la tastatură pentru conducerea unei mașini) către Coliseu;
  • încarcă sprite-uri de joc și folosește un randament pentru a desena lumea fizicii pe un element de pânză;
  • omite detectarea coliziunilor (folosind opțiunea isSensor pentru obiecte);
  • actualizări folosind requestAnimationFrame, ideal la 60 fps.
Diagrama care arată două blocuri principale: aplicația Colyseus Server și aplicația SvelteKit. Aplicația Colyseus Server conține blocul Autowuzzler Room, aplicația SvelteKit conține blocul Client Colyseus. Ambele blocuri principale au un bloc numit Physics World (Matter.js)
Principalele unități logice ale arhitecturii Autowuzzler: Lumea Fizicii este partajată între serverul Colyseus și aplicația client SvelteKit. (Previzualizare mare)

Acum, cu toată magia care se întâmplă pe server, clientul se ocupă doar de intrare și atrage pe ecran starea pe care o primește de la server. Cu o singură excepție:

Interpolare asupra clientului

Deoarece reutilizam aceeași lume fizică Matter.js pe client, putem îmbunătăți performanța cu experiență cu un truc simplu. În loc să actualizăm doar poziția unui obiect de joc, sincronizăm și viteza obiectului . În acest fel, obiectul continuă să se miște pe traiectoria sa, chiar dacă următoarea actualizare de la server durează mai mult decât de obicei. Deci, în loc să mutăm obiectele în pași discreti din poziția A în poziția B, le schimbăm poziția și le facem să se miște într-o anumită direcție.

Ciclu de viață

Clasa Autowuzzler Room este locul în care este tratată logica referitoare la diferitele faze ale unei camere Colizeu. Colyseus oferă mai multe metode ciclului de viață:

  • onCreate : când se creează o cameră nouă (de obicei când se conectează primul client);
  • onAuth : ca un cârlig de autorizare pentru a permite sau a interzice intrarea în cameră;
  • onJoin : când un client se conectează la cameră;
  • onLeave : când un client se deconectează de la cameră;
  • onDispose : când camera este aruncată.

Camera Autowuzzler creează o nouă instanță a lumii fizicii (vezi secțiunea „Fizica într-o aplicație Colyseus”) de îndată ce este creată ( onCreate ) și adaugă un jucător în lume atunci când un client se conectează ( onJoin ). Apoi actualizează lumea fizicii de 60 de ori pe secundă (la fiecare 16,6 milisecunde) folosind metoda setSimulationInterval (bucla principală a jocului):

 // deltaTime is roughly 16.6 milliseconds this.setSimulationInterval((deltaTime) => this.world.updateWorld(deltaTime));

Obiectele de fizică sunt independente de obiectele Colizeu, ceea ce ne lasă cu două permutări ale aceluiași obiect de joc (cum ar fi mingea), adică un obiect din lumea fizicii și un obiect Colizeu care poate fi sincronizat.

De îndată ce obiectul fizic se schimbă, proprietățile sale actualizate trebuie să fie aplicate înapoi obiectului Colizeu. Putem realiza asta ascultând evenimentul afterUpdate al afterUpdate și setând valorile de acolo:

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

Mai există o copie a obiectelor de care trebuie să ne îngrijim: obiectele de joc din jocul orientat spre utilizator .

Diagramă care arată cele trei versiuni ale unui obiect de joc: obiecte Schema Colyseus, obiecte fizice Matter.js, obiecte fizică Matter.js client. Matter.js actualizează versiunea Colyseus a obiectului, Colyseus se sincronizează cu obiectul de fizică Matter.js client.
Autowuzzler păstrează trei copii ale fiecărui obiect de fizică, o versiune autorizată (obiectul Colyseus), o versiune în lumea fizică Matter.js și o versiune pe client. (Previzualizare mare)

Aplicație pe partea clientului

Acum că avem o aplicație pe server care se ocupă de sincronizarea stării jocului pentru mai multe camere, precum și de calcule fizice, să ne concentrăm pe construirea site-ului web și a interfeței actuale a jocului . Interfața Autowuzzler are următoarele responsabilități:

  • permite utilizatorilor să creeze și să partajeze coduri PIN de joc pentru a accesa camere individuale;
  • trimite PIN-urile de joc create la o bază de date Supabase pentru persistență;
  • oferă o pagină opțională „Alăturați-vă unui joc” pentru ca jucătorii să introducă codul PIN al jocului;
  • validează PIN-urile jocului atunci când un jucător se alătură unui joc;
  • găzduiește și redă jocul real pe o adresă URL care poate fi partajată (adică unică);
  • se conectează la serverul Colyseus și gestionează actualizările de stare;
  • oferă o pagină de destinație („marketing”).

Pentru implementarea acelor sarcini, am ales SvelteKit în locul Next.js din următoarele motive:

De ce SvelteKit?

Mi-am dorit să dezvolt o altă aplicație folosind Svelte încă de când am construit neolightsout. Când SvelteKit (cadru oficial de aplicație pentru Svelte) a intrat în versiunea beta publică, am decis să construiesc Autowuzzler cu acesta și să accept orice dureri de cap care vin odată cu utilizarea unei noi beta - bucuria de a folosi Svelte compensează în mod clar.

Aceste caracteristici cheie m-au făcut să aleg SvelteKit în locul Next.js pentru implementarea reală a front-end-ului jocului:

  • Svelte este un cadru UI și un compilator și, prin urmare, furnizează cod minim fără un timp de rulare a clientului;
  • Svelte are un limbaj de șablon expresiv și un sistem de componente (preferință personală);
  • Svelte include magazine globale, tranziții și animații din cutie, ceea ce înseamnă: fără oboseală de decizie, alegerea unui set de instrumente de management global de stat și a unei biblioteci de animații;
  • Svelte acceptă CSS în domeniul de aplicare în componente cu un singur fișier;
  • SvelteKit acceptă SSR, rutare bazată pe fișiere simplă, dar flexibilă și rute pe partea serverului pentru construirea unui API;
  • SvelteKit permite fiecărei pagini să ruleze cod pe server, de exemplu să preia date care sunt folosite pentru a randa pagina;
  • Aspecte partajate pe rute;
  • SvelteKit poate fi rulat într-un mediu fără server.

Crearea și stocarea codurilor PIN pentru jocuri

Înainte ca un utilizator să poată începe să joace jocul, trebuie mai întâi să creeze un cod PIN pentru joc. Partajând PIN-ul altora, toți pot accesa aceeași sală de jocuri.

Captură de ecran a secțiunii de începere a unui joc nou a site-ului web Autowuzzler care arată PIN-ul jocului 751428 și opțiunile de copiere și partajare a PIN-ului și URL-ului jocului.
Începeți un joc nou prin copierea codului PIN generat de joc sau partajați linkul direct către sala de joc. (Previzualizare mare)

Acesta este un caz de utilizare excelent pentru punctele finale de pe server SvelteKits împreună cu funcția Sveltes onMount: punctul final /api/createcode generează un PIN de joc, îl stochează într-o bază de date Supabase.io și emite PIN-ul de joc ca răspuns . Acest răspuns este preluat de îndată ce componenta de pagină a paginii „creați” este montată:

Diagrama care arată trei secțiuni: Creați pagina, createcode endpoint și Supabase.io. Pagina Creare preia punctul final în funcția sa onMount, punctul final generează un PIN de joc, îl stochează în Supabase.io și răspunde cu PIN-ul de joc. Pagina Creare afișează apoi PIN-ul jocului.
PIN-urile jocului sunt create în punctul final, stocate într-o bază de date Supabase.io și afișate pe pagina „Creează”. (Previzualizare mare)

Stocarea codurilor PIN de joc cu Supabase.io

Supabase.io este o alternativă open-source la Firebase. Supabase facilitează crearea unei baze de date PostgreSQL și accesarea acesteia fie prin intermediul uneia dintre bibliotecile sale client, fie prin REST.

Pentru clientul JavaScript, importăm funcția createClient și o executăm folosind parametrii supabase_url și supabase_key pe care i-am primit la crearea bazei de date. Pentru a stoca PIN-ul jocului care este creat la fiecare apel către createcode final de creare a codului, tot ce trebuie să facem este să rulăm această interogare simplă de 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 }]);

Notă : supabase_url și supabase_key sunt stocate într-un fișier .env. Datorită Vite — instrumentul de construcție din centrul SvelteKit — este necesar să se prefixeze variabilele de mediu cu VITE_ pentru a le face accesibile în SvelteKit.

Accesarea jocului

Am vrut să fac înscrierea la un joc Autowuzzler la fel de ușor ca să urmăresc un link. Prin urmare, fiecare sală de jocuri trebuia să aibă propriul URL bazat pe PIN-ul de joc creat anterior , de exemplu https://autowuzzler.com/play/12345.

În SvelteKit, paginile cu parametri dinamici de rută sunt create punând părțile dinamice ale traseului între paranteze drepte atunci când se denumește fișierul de pagină: client/src/routes/play/[gamePIN].svelte . Valoarea parametrului gamePIN va deveni apoi disponibilă în componenta paginii (consultați documentele SvelteKit pentru detalii). În traseul de play , trebuie să ne conectăm la serverul Colyseus, să instanțiem lumea fizică pentru a fi redată pe ecran, să gestionăm actualizările obiectelor de joc, să ascultăm intrarea de la tastatură și să afișăm alte interfețe de utilizare, cum ar fi scorul și așa mai departe.

Conectarea la Coliseu și starea de actualizare

Biblioteca client Colyseus ne permite să conectăm un client la un server Colyseus. Mai întâi, să creăm un nou Colyseus.Client îndreptându-l către serverul Colyseus ( ws://localhost:2567 în dezvoltare). Apoi alăturați-vă camerei cu numele pe care l-am ales mai devreme ( autowuzzler ) și PIN-ul gamePIN din parametrul rută. Parametrul gamePIN se asigură că utilizatorul se alătură instanței corecte de cameră (vezi „match-making” de mai sus).

 let client = new Colyseus.Client("ws://localhost:2567"); this.room = await client.joinOrCreate("autowuzzler", { gamePIN });

Deoarece SvelteKit redă paginile pe server inițial, trebuie să ne asigurăm că acest cod rulează pe client numai după ce pagina este încărcată. Din nou, folosim funcția de ciclu de viață onMount pentru acel caz de utilizare. (Dacă sunteți familiarizat cu React, onMount este similar cu cârligul useEffect cu o matrice de dependențe goală.)

 onMount(async () => { let client = new Colyseus.Client("ws://localhost:2567"); this.room = await client.joinOrCreate("autowuzzler", { gamePIN }); })

Acum că suntem conectați la serverul de joc Colyseus, putem începe să ascultăm orice modificări aduse obiectelor noastre de joc.

Iată un exemplu despre cum să ascultați un jucător care intră în cameră ( onAdd ) și primește actualizări consecutive de stare pentru acest player:

 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 }); }; };

În metoda updatePlayer a lumii fizicii, actualizăm proprietățile una câte una, deoarece onChange al lui onChange oferă un set de toate proprietățile modificate.

Notă : Această funcție rulează numai pe versiunea client a lumii fizicii, deoarece obiectele jocului sunt manipulate doar indirect prin serverul Coliseu.

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

Aceeași procedură se aplică și celorlalte obiecte de joc (minge și echipe): ascultați modificările acestora și aplicați valorile modificate în lumea fizică a clientului.

Până acum, niciun obiect nu se mișcă, deoarece mai trebuie să ascultăm intrarea de la tastatură și să o trimitem la server . În loc să trimitem direct evenimente la fiecare eveniment de keydown , menținem o hartă a tastelor apăsate în prezent și trimitem evenimente către serverul Colyseus într-o buclă de 50 ms. În acest fel, putem sprijini apăsarea mai multor taste în același timp și atenuăm pauza care are loc după primul și succesiv evenimente de keydown când tasta rămâne apăsată:

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

Acum ciclul este complet: ascultați apăsările de taste, trimiteți comenzile corespunzătoare serverului Coliseu pentru a manipula lumea fizicii de pe server. Serverul Colyseus aplică apoi noile proprietăți fizice tuturor obiectelor de joc și propagă datele înapoi către client pentru a actualiza instanța jocului orientată spre utilizator.

Deranjamente minore

Privind retrospectiv, două lucruri din categoria nimeni-nu-mi-a spus-dar-cineva-ar fi trebuit să-mi vină în minte:

  • O bună înțelegere a modului în care funcționează motoarele fizice este benefică. Am petrecut o cantitate considerabilă de timp regland proprietățile și constrângerile fizice. Chiar dacă am construit un mic joc cu Phaser.js și Matter.js înainte, au fost multe încercări și erori pentru a face obiectele să se miște în modul în care mi le-am imaginat.
  • Timpul real este greu – mai ales în jocurile bazate pe fizică. Întârzierile minore înrăutățesc considerabil experiența și, deși starea de sincronizare între clienții cu Colyseus funcționează excelent, nu poate elimina întârzierile de calcul și transmisie.

Probleme și avertismente cu SvelteKit

Deoarece am folosit SvelteKit când era proaspăt ieșit din cuptorul beta, au existat câteva probleme și avertismente pe care aș dori să le subliniez:

  • A durat ceva timp pentru a ne da seama că variabilele de mediu trebuie să fie prefixate cu VITE_ pentru a le utiliza în SvelteKit. Acest lucru este acum documentat în mod corespunzător în Întrebări frecvente.
  • Pentru a folosi Supabase, a trebuit să adaug Supabase atât la listele de dependencies , cât și la devDependencies ale pachetului.json. Cred că nu mai este cazul.
  • Funcția load SvelteKits rulează atât pe server, cât și pe client!
  • Pentru a activa înlocuirea completă a modulului la cald (inclusiv starea de conservare), trebuie să adăugați manual o linie de comentariu <!-- @hmr:keep-all --> în componentele paginii dvs. Consultați Întrebări frecvente pentru mai multe detalii.

Multe alte cadre s-ar fi potrivit, de asemenea, dar nu regret că am ales SvelteKit pentru acest proiect. Mi-a permis să lucrez la aplicația client într-un mod foarte eficient - mai ales pentru că Svelte în sine este foarte expresiv și omite o mare parte din codul standard, dar și pentru că Svelte are lucruri precum animații, tranziții, CSS cu scop și magazine globale. SvelteKit a furnizat toate blocurile de care aveam nevoie (SSR, rutare, rute de server) și, deși încă în versiune beta, sa simțit foarte stabil și rapid.

Implementare și găzduire

Inițial, am găzduit serverul Colyseus (Node) pe o instanță Heroku și am pierdut mult timp pentru ca WebSockets și CORS să funcționeze. După cum se dovedește, performanța unui dyno minuscul (gratuit) Heroku nu este suficientă pentru un caz de utilizare în timp real. Mai târziu, am migrat aplicația Coliseu pe un server mic la Linode. Aplicația pe partea client este implementată de și găzduită pe Netlify prin SvelteKits adapter-netlify. Nicio surpriză aici: Netlify a funcționat grozav!

Concluzie

Începând cu un prototip foarte simplu pentru a valida ideea m-a ajutat foarte mult să-mi dau seama dacă proiectul merită urmărit și unde sunt provocările tehnice ale jocului. În implementarea finală, Colyseus s-a ocupat de toate sarcinile grele ale stării de sincronizare în timp real pe mai mulți clienți, distribuiți în mai multe camere. Este impresionant cât de repede poate fi creată o aplicație multi-utilizator în timp real cu Colyseus - odată ce vă dați seama cum să descrieți corect schema. Panoul de monitorizare încorporat al lui Colyseus ajută la depanarea oricăror probleme de sincronizare.

Ceea ce a complicat această configurare a fost stratul de fizică al jocului, deoarece a introdus o copie suplimentară a fiecărui obiect de joc legat de fizică care trebuia întreținut. Stocarea codurilor PIN de joc în Supabase.io din aplicația SvelteKit a fost foarte simplă. În retrospectivă, aș fi putut să folosesc o bază de date SQLite pentru a stoca PIN-urile jocului, dar încercarea de lucruri noi este jumătate din distracția atunci când construiesc proiecte secundare.

În cele din urmă, utilizarea SvelteKit pentru construirea front-end-ului jocului mi-a permis să mă mișc rapid - și cu un rânjet ocazional de bucurie pe față.

Acum, continuă și invită-ți prietenii la o rundă de Autowuzzler!

Citiți suplimentare despre Smashing Magazine

  • „Începeți cu React prin construirea unui joc Whac-A-Mole”, Jhey Tompkins
  • „Cum să construiți un joc de realitate virtuală multiplayer în timp real”, Alvin Wan
  • „Scrierea unui motor de aventură text multiplayer în Node.js”, Fernando Doglio
  • „Viitorul designului web mobil: design de jocuri video și povestire”, Suzanne Scacca
  • „Cum să construiești un joc de alergători fără sfârșit în realitate virtuală”, Alvin Wan