Construirea unui joc în rețea multiplayer peer-to-peer

Redarea unui joc multiplayer este întotdeauna distractivă. În loc să bată adversarii controlați de AI, jucătorul trebuie să facă față strategiilor create de o altă ființă umană. Acest tutorial prezintă implementarea unui joc multiplayer jucat în rețea utilizând o abordare non-autoritătivă peer-to-peer (P2P).

Notă: Deși acest tutorial este scris folosind AS3 și Flash, ar trebui să puteți utiliza aceleași tehnici și concepte în aproape orice mediu de dezvoltare a jocului. Trebuie să aveți o înțelegere de bază a comunicării în rețea.

Puteți descărca sau introduceți codul final din repo GitHub sau din fișierele sursă de tip zip. Dacă doriți să găsiți resurse unice pentru propriul dvs. joc, verificați selecția activelor jocului pe Envato Market.


Rezultatul final al rezultatelor

Demo de rețea. Controale: săgeți sau WASD a muta, Spaţiu a trage, B să depună o bombă.

Arta din grafica Tyrian remasterizata, placa de fier si vidul dur de Daniel Cook (Lost Garden).


Introducere

Un joc multiplayer jucat în rețea poate fi implementat folosind mai multe abordări diferite, care pot fi clasificate în două grupuri: autoritar și neautoritarului.

În grupul autoritar, cea mai comună abordare este client-server, unde o entitate centrală (serverul autoritar) controlează întregul joc. Fiecare client conectat la server primește în mod constant date, creând local o reprezentare a stării jocului. E un pic cam ca te uiți la televizor.

Implementare autoritară utilizând arhitectura client-server.

Dacă un client efectuează o acțiune, cum ar fi mutarea dintr-un punct în altul, acele informații sunt trimise la server. Serverul verifică dacă informațiile sunt corecte, apoi actualizează starea jocului. După aceea, aceasta promovează informațiile tuturor clienților, astfel încât să își poată actualiza starea de joc în consecință.

În grupul neautorizat, nu există o entitate centrală și fiecare joc (joc) controlează starea jocului. Într-o abordare peer-to-peer (P2P), un coleg trimite date tuturor celorlalți colegi și primește date de la ei, presupunând că informațiile sunt fiabile și corecte (fără cheating):

Implementarea neautorizată folosind arhitectura P2P.

În acest tutorial am prezentat implementarea unui joc multiplayer jucat în rețea utilizând o abordare P2P non-autoritară. Jocul este un arena deathmatch unde fiecare jucător controlează o navă capabilă să tragă și să prindă bombe.

Mă voi concentra asupra comunicării și sincronizării statelor peer. Jocul și codul de rețea sunt extrase cât mai mult posibil din motive de simplificare.

Bacsis: abordarea autoritară este mai sigură împotriva înșelăciunii, deoarece serverul controlează complet starea jocului și poate ignora orice mesaj suspect, cum ar fi o entitate care spune că a mutat 200 pixeli atunci când ar fi putut fi mutat doar 10.

Definirea unui joc non-autoritar

Un joc non-autoritar multiplayer nu are nicio entitate centrală care să controleze starea jocului, astfel încât fiecare coleg trebuie să-și controleze propria stare de joc, comunicând orice schimbări și acțiuni importante celorlalți. În consecință, jucătorul vede două scenarii simultan: nava sa se deplasează în funcție de intrarea lui și a simulare din toate celelalte nave controlate de adversari:

Navele jucătorului sunt controlate la nivel local. Navele adversare sunt simulate pe baza comunicării în rețea.

Mișcarea și acțiunile navei jucătorului sunt ghidate de intrarea locală, astfel încât starea jocului jucătorului este actualizată aproape instantaneu. Pentru mișcarea tuturor celorlalte nave, jucătorul trebuie să primească un mesaj de rețea de la fiecare adversar care informează unde sunt navele lor.

Aceste mesaje au nevoie de timp pentru a călători în rețea de la un computer la altul, astfel încât atunci când jucătorul primește o informație spunând că nava adversarului este la (X y), probabil că nu mai există - de aceea este o simulare:

Întârziere de comunicare cauzată de rețea.

Pentru a păstra corectitudinea simulării, fiecare coleg este responsabil de propagare numai informațiile despre navă, nu despre celelalte. Aceasta înseamnă că, dacă jocul are patru jucători - să zicem A, B, C și D - jucător A este singurul capabil să informeze unde nava A este, dacă a fost lovit, dacă a tras un glonț sau a aruncat o bombă și așa mai departe. Toți ceilalți jucători vor primi mesaje de la A informând despre acțiunile sale și vor reacționa în consecință, așa că dacă La fel de bullet a primit lui C nava, atunci C va difuza un mesaj prin care informează că a fost distrus.

În consecință, fiecare jucător va vedea toate celelalte nave (și acțiunile lor) în funcție de mesajele primite. Într-o lume perfectă, nu ar exista o latență a rețelei, astfel încât mesajele să vină și să meargă instantaneu, iar simularea ar fi extrem de precisă.

Pe măsură ce crește latența, simularea devine inexactă. De exemplu, jucătorul A trage și vede pe loc glonțul lovit Bnava, dar nu se întâmplă nimic; asta este pentru ca Ade vedere B este întârziată din cauza întârzierii rețelei. Cand B a primit efectiv Amesajul bullet, B se afla într-o poziție diferită, astfel încât nu sa propagat niciun succes.


Maparea acțiunilor relevante

Un pas important în implementarea jocului și asigurarea faptului că fiecare jucător va putea să vadă exact aceeași simulare este identificarea acțiunilor relevante. Aceste acțiuni schimbă starea curentă a jocului, cum ar fi mutarea dintr-un punct în altul, abandonarea unei bombe etc..

În jocul nostru, acțiunile importante sunt:

  • trage (nava jucătorului a tras un glonț sau o bombă)
  • mișcare (nava jucătorului sa mutat)
  • a muri (nava jucătorului a fost distrusă)
Acțiunile jucătorului în timpul jocului.

Fiecare acțiune trebuie trimisă prin rețea, deci este important să găsiți un echilibru între cantitatea de acțiuni și dimensiunea mesajelor de rețea pe care acestea le vor genera. Cu cât mesajul este mai mare (cu alte cuvinte, cu atât mai multe date conțin), cu atât va dura mai mult timp pentru a fi transportat, deoarece ar putea avea nevoie de mai mult de un pachet de rețea.

Mesajele scurte necesită mai puțin timp CPU pentru a împacheta, a trimite și a despacheta. Mesajele de rețea mici generează, de asemenea, mai multe mesaje trimise în același timp, ceea ce mărește capacitatea de transfer.


Efectuarea de acțiuni independent

După ce acțiunile relevante sunt cartografiate, este timpul să le facem reproductibile fără intrarea utilizatorilor. Chiar dacă acesta este un principiu al ingineriei bune a software-ului, s-ar putea să nu fie evident din punctul de vedere al jocului multiplayer.

Folosind acțiunea de fotografiere a jocului nostru ca exemplu, dacă este profund interconectată cu logica de intrare, nu este posibil să reutilizați același cod de fotografiere în diferite situații:

Efectuați acțiuni independent.

Când codul de fotografiere este decuplat de logica de intrare, de exemplu, este posibil să utilizați același cod pentru a trage gloanțele jucătorului și gloanțele adversarului (când apare un astfel de mesaj de rețea). Evită replicarea codului și previne o durere de cap.

Navă clasa din jocul nostru, de exemplu, nu are cod multiplayer; este complet decuplat. Descrie o navă, fie ea locală sau nu. Clasa, totuși, are mai multe metode de manipulare a navei, cum ar fi roti() și un setter pentru a-și schimba poziția. În consecință, codul multiplayer poate roti o navă la fel ca și codul de intrare al utilizatorului - diferența este că una se bazează pe intrarea locală, în timp ce cealaltă se bazează pe mesaje de rețea.


Schimbarea datelor bazate pe acțiuni

Acum, că toate acțiunile relevante sunt cartografiate, este timpul să schimbăm mesajele între colegi pentru a crea simularea. Înainte de a schimba orice date, trebuie formulat un protocol de comunicare. În ceea ce privește comunicarea unui joc multiplayer, un protocol poate fi definit ca un set de reguli care descriu modul în care este structurat un mesaj, astfel încât toată lumea să poată trimite, citi și înțelege acele mesaje.

Mesajele schimbate în joc vor fi descrise ca obiecte, toate conținând o proprietate obligatorie numită op (cod de operare). op este utilizat pentru a identifica tipul de mesaj și pentru a indica proprietățile obiectului mesajului. Aceasta este structura tuturor mesajelor:

structura mesajelor de rețea.
  • OP_DIE mesajul spune că o navă a fost distrusă. Este X și y proprietățile conțin locația navei când a fost distrusă.
  • OPOZIŢIE mesajul conține locația curentă a navei unui coleg. Este X și y proprietățile conțin coordonatele navei pe ecran, în timp ce unghi este unghiul de rotație actual al navei.
  • OP_SHOT mesajul spune că o navă a tras ceva (un glonț sau o bombă). X și y proprietățile conțin locația navei atunci când a fost concediată; dx și dy proprietățile indică direcția navei, care asigură faptul că glonțul va fi replicat la toți colegii, folosind același unghi pe care nava de foc a fost utilizată atunci când a țintit; si b proprietatea defineste tipul de proiectil (glonţ sau bombă).

Multiplayer Clasă

Pentru a organiza codul multiplayer, creăm un Multiplayer clasă. Acesta este responsabil pentru trimiterea și primirea de mesaje, precum și actualizarea navelor locale în funcție de mesajele primite pentru a reflecta starea actuală a simulării jocului.

Structura sa inițială, care conține numai codul de mesaj, este:

clasa publica Multiplayer public const OP_SHOT: String = "S"; public const OP_DIE: String = "D"; public const OP_POSITION: String = "P"; funcția publică Multiplayer () // Codul de conexiune a fost omis.  funcția publică sendObject (obj: Object): void // Codul de rețea folosit pentru trimiterea obiectului a fost omis. 

Trimiterea mesajelor de acțiune

Pentru fiecare acțiune relevantă cartografiată anterior, trebuie trimis un mesaj de rețea, astfel încât toți colegii să fie informați despre acea acțiune.

OP_DIE acțiunea trebuie trimisă atunci când jucătorul este lovit de un glonț sau o explozie de bombe. Există deja o metodă în codul de joc care distruge nava jucătorului atunci când este lovită, deci este actualizată pentru a răspândi acele informații:

funcția publică pePlayerHitByBullet (): void // Destoy player player shipShip.kill (); // MULTIPLAYER: Trimite un mesaj tuturor celorlalți jucători care informează că nava a fost distrusă. multiplayer.sendObject (op: Multiplayer.OP_DIE, x: platerShip.x, y: playerShip.y); 

OPOZIŢIE acțiunea trebuie trimisă de fiecare dată când jucătorul își schimbă poziția actuală. Codul multiplayer este injectat în codul de joc pentru a propaga și acele informații:

funcția publică funcția updatePlayerInput (): void var mut: Boolean = false; dacă (wasMoveKeysPressed ()) playerShip.x + = playerShip.direction.x; jucatorShip.y + = jucatorShip.direction.y; mutat = adevărat;  dacă (a fostRotateKeysPressed ()) playerShip.rotate (10); mutat = adevărat;  // MULTIPLAYER: // Dacă jucătorul a fost mutat (sau rotit), propagați informațiile. dacă (mutat) multiplayer.sendObject (op: Multiplayer.OP_POSITION, x: playerShip.x, y: playerShip.y, unghi: playerShip.angle); 

În cele din urmă, OP_SHOT acțiunea trebuie trimisă de fiecare dată când jucătorul declanșează ceva. Mesajul trimis conține tipul de bullet care a fost declanșat, astfel încât fiecare coleg să vadă proiectilul corect:

dacă (fostShootingKeysPressed ()) var bulletType: Class = getBulletType (); game.shoot (jucatorShip, bulletType); // MULTIPLAYER: // Informați toți ceilalți jucători că am tras un proiectil. multiplayer.sendObject (op: Multiplayer.OP_SHOT, x: playerShip.x, y: playerShip.y, dx: playerShip.direction.x, dy: playerShip.direction.y, b: bBulletType)); 

Sincronizarea bazată pe datele primite

În acest moment, fiecare jucător este în măsură să controleze și să vadă nava lor. Sub capota, mesajele de rețea sunt trimise pe baza unor acțiuni relevante. Singura piesă lipsă este adăugarea adversarilor, astfel încât fiecare jucător să poată vedea celelalte nave și să interacționeze cu ei.

În joc, navele sunt organizate ca o matrice. Această gamă avea doar o singură navă (jucătorul) până acum. Pentru a crea simularea pentru toți ceilalți jucători, Multiplayer clasa va fi schimbată pentru a adăuga o navă nouă la matricea respectivă ori de câte ori un nou jucător se alătură scenei:

clasa publica Multiplayer public const OP_SHOT: String = "S"; public const OP_DIE: String = "D"; public const OP_POSITION: String = "P"; (...) // Această metodă este invocată de fiecare dată când un nou utilizator se alătură arenei. funcția protejată handleUserAdded (utilizator: UserObject): void // Creați o nouă bază de navă pe ID-ul noului utilizator. var nav: navă = navă nouă (user.id); // Adăugați nava la o serie de nave deja existente. game.ships.add (navă); 

Codul de schimb de mesaje oferă automat un identificator unic pentru fiecare jucător ( numele de utilizator în codul de mai sus). Această identificare este utilizată de codul multiplayer pentru a crea o navă nouă atunci când un jucător se alătură scenei; în acest fel, fiecare navă are un identificator unic. Folosind identificatorul autorului pentru fiecare mesaj primit, este posibil să căutați acea navă în gama de nave.

În cele din urmă, este timpul să adăugați handleGetObject () la Multiplayer clasă. Această metodă este invocată de fiecare dată când apare un mesaj nou:

clasa publica Multiplayer public const OP_SHOT: String = "S"; public const OP_DIE: String = "D"; public const OP_POSITION: String = "P"; (...) // Această metodă este invocată de fiecare dată când un nou utilizator se alătură arenei. funcția protejată handleUserAdded (utilizator: UserObject): void // Creați o nouă bază de navă pe ID-ul noului utilizator. var nav: navă = navă nouă (user.id); // Adăugați nava la o serie de nave deja existente. game.ships.add (navă);  funcția protejată handleGetObject (userId: String, data: Object): void var opCode: String = data.op; // Gasiti nava jucatorului care a trimis mesajul var ship: Ship = getShipById (userId); comutare (opCode) caz OP_POSITION: // Mesaj pentru actualizarea poziției navei autorului. ship.x = data.x; ship.y = data.y; ship.angle = data.angle; pauză; cazul OP_SHOT: // Mesajul informând nava autorului a tras un proiector. În primul rând, actualizați poziția și direcția navei. ship.x = data.x; ship.y = data.y; ship.direction.x = data.dx; ship.direction.y = data.dy; / / Focul de proiectil de la locul navei autorului. game.shoot (navă, date.b); pauză; cazul OP_DIE: // Mesajul care informează nava autorului a fost distrus. ship.kill (); pauză; 

Când soseste un mesaj nou, handleGetObject () metoda este invocată cu doi parametri: ID-ul autorului (identificatorul unic) și datele mesajului. Analizând datele mesajului, se extrage codul de funcționare și, pe baza acestuia, se extrag toate celelalte proprietăți.

Folosind datele extrase, codul multiplayer reproduce toate acțiunile care au fost primite prin rețea. Luând OP_SHOT mesaj ca exemplu, aceștia sunt pașii efectuate pentru actualizarea stării actuale a jocului:

  1. Căutați nava locală identificată de numele de utilizator.
  2. Actualizați Navăpoziția și unghiul în funcție de datele primite.
  3. Actualizați Navăîn funcție de datele primite.
  4. Invocați metoda de joc responsabilă pentru arderea proiectilelor, aruncarea unui glonț sau a unei bombe.

După cum sa descris anterior, codul de înregistrare este decuplat de player și de logica de intrare, astfel încât proiectilul tras comportă exact ca unul tras de către jucător pe plan local.


Atenuarea problemelor de întârziere

Dacă jocul deplasează exclusiv entități bazate pe actualizări de rețea, orice mesaj pierdut sau întârziat va determina entitatea să "teleporteze" de la un punct la altul. Acest lucru poate fi atenuat cu previziunile locale.

Folosind interpolarea, de exemplu, mișcarea entității este interpolată local dintr-un punct în altul (ambele primite de către actualizările de rețea). Ca urmare, entitatea se va deplasa fără probleme între aceste puncte. În mod ideal, latența nu trebuie să depășească timpul necesar unei entități pentru a fi interpolat de la un punct la altul.

Un alt truc este extrapolarea, care mișcă local entități bazate pe starea sa actuală. Se presupune că entitatea nu își va schimba ruta actuală, deci este sigur să o deplasăm în funcție de direcția și viteza actuale, de exemplu. Dacă latența nu este prea mare, extrapolarea reproduce cu exactitate mișcarea așteptată a entității până când ajunge la o nouă actualizare de rețea, rezultând un model de mișcare netedă.

În ciuda acestor trucuri, latența rețelei poate fi extrem de ridicată și uneori imposibil de gestionat. Cea mai ușoară abordare a eliminării este aceea de a deconecta colegii problematici. O abordare sigură în acest sens este folosirea unui timeout: în cazul în care partenerul ia mai mult de un anumit timp pentru a răspunde, acesta este deconectat.


Concluzie

Efectuarea unui joc multiplayer jucat în rețea este o sarcină provocatoare și interesantă. Este nevoie de o modalitate diferită de a vedea lucrurile, deoarece toate acțiunile relevante trebuie trimise și reproduse de toți colegii. În consecință, toți jucătorii văd o simulare a ceea ce se întâmplă, cu excepția navei locale, care nu are latență de rețea.

Acest tutorial a descris implementarea unui joc multiplayer folosind o abordare non-autoritară P2P. Toate conceptele prezentate pot fi extinse pentru a implementa diferite mecanisme multiplayer. Să înceapă jocul pentru multiplayeri!