Crearea unui joc web multiplayer în genul .io

Crearea unui joc web multiplayer în genul .io
Lansată în 2015 Agar.io a fost părintele unui nou gen de jocuri .io, popularitatea cărora a crescut considerabil de atunci. Am trăit creșterea popularității jocurilor .io personal: în ultimii trei ani am creat și vândut două jocuri din acest gen..

În cazul în care nu ați auzit niciodată despre aceste jocuri: sunt jocuri web multiplayer gratuite, la care este ușor să participi (nu este necesar un cont). De obicei, ele reunesc pe aceeași arenă mulți jucători competitori. Alte jocuri celebre din genul .io: Slither.io și Diep.io.

În această postare vom învăța cum să creăm un joc .io de la zero. Pentru asta este suficient să aveți cunoștințe de Javascript: trebuie să înțelegeți lucruri precum sintaxa ES6, cuvântul cheie this și Promises. Chiar dacă nu stăpâniți perfect Javascript, totuși veți putea înțelege cea mai mare parte a postării.

Exemplu de joc .io

Pentru a ajuta la învățare, ne vom referi la exemplul de joc .io. Încercați să jucați!

Crearea unui joc web multiplayer în genul .io
Jocul este destul de simplu: controlați o navă pe arenă, unde se află alți jucători. Nava dumneavoastră trage automat proiectile și încercați să loviți alți jucători, în timp ce evitați proiectilele lor.

1. Prezentare generală / structură de proiect

Recomand să descărcați codul sursă exemplului de joc, astfel încât să mă puteți urma.

În exemplu se folosește următoarele:

  • Express — cel mai popular framework web pentru Node.js, care gestionează serverul de joc.
  • socket.io — bibliotecă websocket pentru schimbul de date între browser și server.
  • Webpack — manager de module. Despre de ce să utilizați Webpack, puteți citi aici.

Iată cum arată structura directorului proiectului:

public/
    assets/
        ...
src/
    client/
        css/
            ...
        html/
            index.html
        index.js
        ...
    server/
        server.js
        ...
    shared/
        constants.js

public/

Totul în folderul public/ va fi transmis static de server. În public/assets/ se află imaginile utilizate de proiectul nostru.

src/

Tot codul sursă se află în folderul src/. Numele client/ și server/ vorbesc de la sine, iar shared/ conține un fișier de constante, importat atât de client, cât și de server.

2. Build-uri / parametrii proiectului

Așa cum am menționat mai sus, pentru a construi proiectul folosim managerul de module Webpack. Haideți să aruncăm o privire asupra configurației noastre Webpack:

webpack.common.js:

const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = {
  entry: {
    game: '.\/src\/client\/index.js',
  },
  output: {
    filename: '[name].[contenthash].js',
    path: path.resolve(__dirname, 'dist'),
  },
  module: {
    rules: [
      {
        test: \/\.js$\/, 
        exclude: \/node_modules\/, 
        use: {
          loader: "babel-loader",
          options: {
            presets: ['@babel\/preset-env'],
          },
        },
      },
      {
        test: \/\.css$\/, 
        use: [
          {
            loader: MiniCssExtractPlugin.loader,
          },
          'css-loader',
        ],
      },
    ],
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: '[name].[contenthash].css',
    }),
    new HtmlWebpackPlugin({
      filename: 'index.html',
      template: 'src\/client\/html\/index.html',
    }),
  ],
};

Cele mai importante aici sunt următoarele linii:

  • src/client/index.js — acesta este punctul de intrare al clientului Javascript (JS). Webpack va începe de aici și va căuta recursiv alte fișiere importate.
  • Fișierul JS de ieșire al construcției noastre Webpack se va afla în catalogul dist\/. Îl voi numi acest fișier ca fiind pachetul JS.
  • Folosim Babel, iar în special configurația @babel\/preset-env pentru transpilearea (transpiling) codului nostru JS pentru browserele vechi.
  • Folosim un plugin pentru a extrage toate CSS-urile la care se fac referire fișierele JS și pentru a le combina într-un singur loc. Îl voi numi acest pachet ca fiind pachetul CSS.

Ați putea observa nume ciudate de fișiere de pachete '[name].[contenthash].ext'. Acestea conțin substituții de nume de fișiere Webpack: [name] va fi înlocuit cu numele punctului de intrare (în cazul nostru acesta este game), iar [contenthash] va fi înlocuit cu hash-ul conținutului fișierului. Facem acest lucru pentru a optimizeaza proiectul pentru hashing — putem ordona browserelor să cacheze fără sfârșit pachetele noastre JS, pentru că dacă pachetul se schimbă, atunci se schimbă și numele fișierului (se schimbă contenthash). Rezultatul final va fi un nume de fișier de genul game.dbeee76e91a97d0c7207.js.

Fișier webpack.common.js — acesta este fișierul de configurare de bază pe care îl importăm în configurațiile de dezvoltare și de producție. Iată, de exemplu, configurația de dezvoltare:

webpack.dev.js

const merge = require('webpack-merge');
const common = require('.\/webpack.common.js');

module.exports = merge(common, {
  mode: 'development',
});

Pentru eficiență, folosim în procesul de dezvoltare webpack.dev.js, și se schimbă pe webpack.prod.js, pentru a optimiza dimensiunea pachetelor atunci când sunt desfășurate în producție.

Configurarea locală

Recomand să instalați proiectul pe mașina locală, astfel încât să puteți urma pașii enumerați în această postare. Configurarea este simplă: mai întâi, în sistem trebuie să fie instalate Node și NPM. Apoi trebuie să executați

$ git clone https:\/\/github.com\/vzhou842\/example-.io-game.git
$ cd example-.io-game
$ npm install

și sunteți gata de acțiune! Pentru a lansa serverul de dezvoltare, este suficient să executați

$ npm run develop

și să accesați în browser localhost:3000. Serverul de dezvoltare va reconstrui automat pachetele JS și CSS pe parcursul modificărilor de cod — pur și simplu reîncărcați pagina pentru a vedea toate modificările!

3. Punctele de acces pentru client

Să trecem la codul jocului. Pentru început, va trebui să avem o pagină index.html, care va fi încărcată prima dată când se vizitează site-ul. Pagina noastră va fi destul de simplă:

index.html

<!DOCTYPE html>
<html>
<head>
  <title>Un exemplu de joc .io</title>
  <link type="text/css" rel="stylesheet" href="/game.bundle.css">
</head>
<body>
  <canvas id="game-canvas"></canvas>
  <script async src="/game.bundle.js"></script>
  <div id="play-menu" class="hidden">
    <input type="text" id="username-input" placeholder="Nume utilizator" />
    <button id="play-button">JOC</button>
  </div>
</body>
</html>

Acest exemplu de cod este ușor simplificat pentru claritate, voi face la fel și cu multe alte exemple din acest articol. Codul complet poate fi văzut întotdeauna pe Github.

Avem:

  • Elementul HTML5 Canvas (<canvas>), pe care îl vom folosi pentru a reda jocul.
  • <link> pentru a adăuga pachetul nostru CSS.
  • <script> pentru a adăuga pachetul nostru Javascript.
  • Meniul principal cu numele utilizatorului <input> și butonul „PLAY” (

După ce pagina de start este încărcată în browser, va începe să se execute codul Javascript, începând cu fișierul JS de intrare: src/client/index.js.

index.js

import { connect, play } from './networking';
import { startRendering, stopRendering } from './render';
import { startCapturingInput, stopCapturingInput } from './input';
import { downloadAssets } from './assets';
import { initState } from './state';
import { setLeaderboardHidden } from './leaderboard';

import './css/main.css';

const playMenu = document.getElementById('play-menu');
const playButton = document.getElementById('play-button');
const usernameInput = document.getElementById('username-input');

Promise.all([
  connect(),
  downloadAssets(),
]).then(() => {
  playMenu.classList.remove('hidden');
  usernameInput.focus();
  playButton.onclick = () => {
    // Play!
    play(usernameInput.value);
    playMenu.classList.add('hidden');
    initState();
    startCapturingInput();
    startRendering();
    setLeaderboardHidden(false);
  };
});

Acest lucru poate părea complicat, dar de fapt nu se întâmplă atât de multe aici:

  1. Importarea câtorva alte fișiere JS.
  2. Importul CSS (pentru ca Webpack să știe că trebuie să le includă în pachetul nostru CSS).
  3. Pornire connect() pentru a stabili conexiunea cu serverul și a începe downloadAssets() pentru descărcarea imaginilor necesare pentru redarea jocului.
  4. După finalizarea etapei 3 meniul principal (playMenu).
  5. Configurarea handlerului pentru butonul „PLAY”. Când butonul este apăsat, codul inițiază jocul și informează serverul că suntem gata să jucăm.

Cea mai importantă parte a logicii noastre client-server se află în acele fișiere care au fost importate de fișierul index.js. Acum le vom analiza pe toate rând pe rând.

4. Schimbul de date între client și server

În acest joc, pentru a comunica cu serverul, folosim o bibliotecă bine cunoscută socket.io. Socket.io are încorporată suport nativ WebSockets, care se potrivesc bine pentru comunicații bidirecționale: putem trimite mesaje serverului și iar serverul ne poate trimite mesaje pe aceeași conexiune.

Vom avea un singur fișier src/client/networking.js, care se va ocupa de toate comunicațiile cu serverul:

networking.js

import io from 'socket.io-client';
import { processGameUpdate } from '.\/state';

const Constants = require('..\/shared\/constants');

const socket = io(`ws:\/\/ ${window.location.host}`);
const connectedPromise = new Promise(resolve => {
  socket.on('connect', () => {
    console.log('Conectat la server!');
    resolve();
  });
});

export const connect = onGameOver => (
  connectedPromise.then(() => {
    \/\/ Înregistrăm callback-uri
    socket.on(Constants.MSG_TYPES.GAME_UPDATE, processGameUpdate);
    socket.on(Constants.MSG_TYPES.GAME_OVER, onGameOver);
  })
);

export const play = username => {
  socket.emit(Constants.MSG_TYPES.JOIN_GAME, username);
};

export const updateDirection = dir => {
  socket.emit(Constants.MSG_TYPES.INPUT, dir);
};

Acest cod este puțin comprimat pentru claritate.

În acest fișier se desfășoară trei acțiuni principale:

  • Încercăm să ne conectăm la server. connectedPromise se rezolvă doar atunci când am stabilit conexiunea.
  • Dacă conexiunea a fost stabilită cu succes, înregistrăm funcțiile callback (processGameUpdate() și onGameOver()) pentru mesajele pe care le putem primi de la server.
  • Exportăm play() și updateDirection(), astfel încât să fie utilizabile de alte fișiere.

5. Redarea clientului

A venit vremea să afișăm imaginea pe ecran!

…dar înainte de a putea face acest lucru, trebuie să descărcăm toate imaginile (resursele) necesare. Să scriem un manager de resurse:

assets.js

const ASSET_NAMES = ['ship.svg', 'bullet.svg'];

const assets = {};
const downloadPromise = Promise.all(ASSET_NAMES.map(downloadAsset));

function downloadAsset(assetName) {
  return new Promise(resolve => {
    const asset = new Image();
    asset.onload = () => {
      console.log(`Descărcat ${assetName}`);
      assets[assetName] = asset;
      resolve();
    };
    asset.src = `\/assets\/${assetName}`;
  });
}

export const downloadAssets = () => downloadPromise;
export const getAsset = assetName => assets[assetName];

Gestionarea resurselor nu este atât de complicată! Ceea ce trebuie să facem este să păstrăm un obiect assets, care va lega cheia numelui fișierului de valoarea obiectului Imagine. Când resursa este descărcată, o salvăm în obiect assets pentru a fi accesibilă rapid în viitor. După ce fiecare resursă individuală este descărcată (adică resursele sunt încărcate), autorizăm tot downloadPromise După descărcarea resurselor, putem începe redarea. Așa cum am spus anterior, pentru a desena pe pagină folosim.

HTML5 Canvas ). Jocul nostru este destul de simplu, deci trebuie doar să desenăm următoarele: (<canvas>). Jocul nostru este destul de simplu, așa că este suficient să desenăm doar următoarele:

  1. Fundal
  2. Nava playerului
  3. Alți jucători care sunt în joc
  4. Proiectile

Iată fragmentele importante src/client/render.js, care ilustrează exact cele patru puncte menționate mai sus:

render.js

import { getAsset } from './assets';
import { getCurrentState } from './state';

const Constants = require('../shared/constants');
const { PLAYER_RADIUS, PLAYER_MAX_HP, BULLET_RADIUS, MAP_SIZE } = Constants;

// Obține contextul grafic al canvas-ului
const canvas = document.getElementById('game-canvas');
const context = canvas.getContext('2d');

// Fă canvas-ul fullscreen
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;

function render() {
  const { me, others, bullets } = getCurrentState();
  if (!me) {
    return;
  }

  // Desenează fundalul
  renderBackground(me.x, me.y);

  // Desenează toate proiectilele
  bullets.forEach(renderBullet.bind(null, me));

  // Desenează toți jucătorii
  renderPlayer(me, me);
  others.forEach(renderPlayer.bind(null, me));
}

// ... Funcții ajutătoare excluse aici

let renderInterval = null;
export function startRendering() {
  renderInterval = setInterval(render, 1000 / 60);
}
export function stopRendering() {
  clearInterval(renderInterval);
}

Acest cod este de asemenea scurtat pentru claritate.

render() — este funcția principală a acestui fișier. startRendering() și stopRendering() controlază activarea ciclului de redare cu o frecvență de 60 FPS.

Implementările specifice ale funcțiilor ajutătoare de redare (de exemplu, renderBullet()) nu sunt atât de importante, dar iată un exemplu simplu:

render.js

function renderBullet(me, bullet) {
  const { x, y } = bullet;
  context.drawImage(
    getAsset('bullet.svg'),
    canvas.width / 2 + x - me.x - BULLET_RADIUS,
    canvas.height / 2 + y - me.y - BULLET_RADIUS,
    BULLET_RADIUS * 2,
    BULLET_RADIUS * 2,
  );
}

Observați că folosim metoda getAsset(), pe care am văzut-o anterior în asset.js!

Dacă sunteți interesat să explorați alte funcții ajutătoare de redare, citiți restul src/client/render.js.

6. Intrare client

A venit timpul să facem jocul jucabil! Schema de control va fi foarte simplă: pentru a schimba direcția de mișcare, puteți folosi mouse-ul (pe computer) sau atingerea ecranului (pe dispozitivele mobile). Pentru a realiza acest lucru, vom înregistra Event Listeners pentru evenimentele Mouse și Touch.
Toate acestea vor fi gestionate de src/client/input.js:

input.js

import { updateDirection } from './networking';

function onMouseInput(e) {
  handleInput(e.clientX, e.clientY);
}

function onTouchInput(e) {
  const touch = e.touches[0];
  handleInput(touch.clientX, touch.clientY);
}

function handleInput(x, y) {
  const dir = Math.atan2(x - window.innerWidth / 2, window.innerHeight / 2 - y);
  updateDirection(dir);
}

export function startCapturingInput() {
  window.addEventListener('mousemove', onMouseInput);
  window.addEventListener('touchmove', onTouchInput);
}

export function stopCapturingInput() {
  window.removeEventListener('mousemove', onMouseInput);
  window.removeEventListener('touchmove', onTouchInput);
}

onMouseInput() și onTouchInput() — sunt Event Listeners care sunt apelate updateDirection() (din networking.js) la momentul unei eveniment de intrare (de exemplu, când se mișcă mouse-ul). updateDirection() se ocupă cu schimbul de mesaje cu serverul, care procesează evenimentul de intrare și actualizează starea jocului în consecință.

7. Starea clientului

Această secțiune este cea mai complicată din prima parte a articolului. Nu te descuraja dacă nu o înțelegi din prima! Poți chiar să o ocolești și să revii la ea mai târziu.

Ultima piesă a puzzle-ului necesară pentru a finaliza codul client-server este state. Îți amintești de fragmentul de cod din secțiunea 'Redarea clientului'?

render.js

import { getCurrentState } from './state';

function render() {
  const { me, others, bullets } = getCurrentState();

  // Facem redarea
  // ...
}

getCurrentState() trebuie să fie capabil să ne ofere starea curentă a jocului în client în orice moment pe baza actualizărilor primite de la server. Iată un exemplu de actualizare a jocului pe care serverul ar putea să-l trimită:

{
  "t": 1555960373725,
  "me": {
    "x": 2213.8050880413657,
    "y": 1469.370893425012,
    "direction": 1.3082443894581433,
    "id": "AhzgAtklgo2FJvwWAADO",
    "hp": 100
  },
  "others": [],
  "bullets": [
    {
      "id": "RUJfJ8Y18n",
      "x": 2354.029197099604,
      "y": 1431.6848318262666
    },
    {
      "id": "ctg5rht5s",
      "x": 2260.546457727445,
      "y": 1456.8088728920968
    }
  ],
  "leaderboard": [
    {
      "username": "Player",
      "score": 3
    }
  ]
}

Fiecare actualizare a jocului conține cinci câmpuri identice:

  • t: marcajul temporal al serverului, care indică momentul creării acestei actualizări.
  • me: informații despre jucătorul care primește această actualizare.
  • others: un array de informații despre alți jucători care participă la același joc.
  • bullets: un array de informații despre proiectilele din joc.
  • leaderboard: datele curente ale tabelului de lideri. În acest articol nu le vom lua în considerare.

7.1 Starea naivă a clientului

O implementare naivă getCurrentState() poate doar să returneze direct datele ultimei actualizări primite a jocului.

naive-state.js

let lastGameUpdate = null;

// Procesăm o actualizare nou primită a jocului.
export function processGameUpdate(update) {
  lastGameUpdate = update;
}

export function getCurrentState() {
  return lastGameUpdate;
}

Frumos și clar! Dar dacă totul ar fi atât de simplu. Unul dintre motivele pentru care o astfel de implementare este problematică: limitează frecvența cadrelor de redare la frecvența de tact a serverului.

Frecvența cadrelor (Frame Rate): numărul de cadre (adică apeluri render()) pe secundă, sau FPS. În jocuri, de obicei se dorește atingerea a cel puțin 60 FPS.

Frecvența de tact (Tick Rate): frecvența la care serverul trimite actualizări ale jocului clienților. De obicei, este mai mică decât frecvența cadrelor. În jocul nostru, serverul funcționează la o frecvență de 30 de tacte pe secundă.

Dacă vom reda doar cea mai recentă actualizare a jocului, atunci, în esență, FPS-ul nu va putea depăși niciodată 30, deoarece nu primim de la server mai mult de 30 de actualizări pe secundă. Chiar dacă am apela render() de 60 de ori pe secundă, jumătate dintre aceste apeluri vor reda pur și simplu același lucru, practic fără a face nimic. O altă problemă a implementării naive este că aceasta este supusă întârzierilor. La o viteză ideală a internetului, clientul va primi actualizarea jocului exact la fiecare 33 ms (30 pe secundă):

Crearea unui joc web multiplayer în genul .io
Din păcate, nimic nu este perfect. O imagine mai realistă ar fi:
Crearea unui joc web multiplayer în genul .io
Implementarea naivă este practic cel mai prost caz în ceea ce privește întârzierile. Dacă actualizarea jocului este primită cu o întârziere de 50 ms, atunci clientul întârzie cu încă 50 ms, deoarece continuă să redea starea jocului din actualizarea anterioară. Îți poți imagina cât de incomod este pentru jucător: din cauza întârzierilor arbitrare, jocul va părea sacadat și instabil.

7.2 Starea clientului îmbunătățită

Vom aduce câteva îmbunătățiri implementării naive. În primul rând, vom folosi întârzierea de redare de 100 ms. Aceasta înseamnă că starea "curentă" a clientului va fi întotdeauna cu 100 ms în urmă față de starea jocului pe server. De exemplu, dacă pe server timpul este 150, atunci pe client va fi redată starea în care se afla serverul în timpul 50:

Crearea unui joc web multiplayer în genul .io
Aceasta ne oferă un tampon de 100 ms, care ne permite să facem față timpului imprevizibil de recepție a actualizărilor jocului:

Crearea unui joc web multiplayer în genul .io
Prețul pentru aceasta va fi o întârziere constantă a inputului de 100 ms. Aceasta este o mică jertfă pentru un proces de joc fluid — majoritatea jucătorilor (în special cei casual) nici nu vor observa această întârziere. Oamenilor le este mult mai ușor să se adapteze la o întârziere constantă de 100 ms decât să joace cu întârzieri imprevizibile.

Putem folosi și o altă tehnică numită "previziune pe partea clientului", care funcționează bine pentru reducerea întârzierilor percepute, dar aceasta nu va fi discutată în această postare.

O altă îmbunătățire pe care o folosim este interpolarea liniară. Din cauza întârzierei de redare, în general suntem cu cel puțin actualizarea cu un pas mai devreme decât timpul curent pe client. Când este apelat getCurrentState(), putem efectua interpolarea liniară între actualizările jocului, direct înainte și după timpul curent în client:

Crearea unui joc web multiplayer în genul .io
Aceasta rezolvă problema cu rata de cadre: acum putem reda cadre unice cu orice rată dorită!

7.3 Implementarea unei stări îmbunătățite a clientului

Exemplu de implementare în src/client/state.js folosește atât întârzierea de redare, cât și interpolarea liniară, dar este temporar. Să împărțim codul în două părți. Iată prima:

state.js, partea 1

const RENDER_DELAY = 100;

const gameUpdates = [];
let gameStart = 0;
let firstServerTimestamp = 0;

export function initState() {
  gameStart = 0;
  firstServerTimestamp = 0;
}

export function processGameUpdate(update) {
  if (!firstServerTimestamp) {
    firstServerTimestamp = update.t;
    gameStart = Date.now();
  }
  gameUpdates.push(update);

  // Păstrează doar o actualizare a jocului înainte de timpul curent al serverului
  const base = getBaseUpdate();
  if (base > 0) {
    gameUpdates.splice(0, base);
  }
}

function currentServerTime() {
  return firstServerTimestamp + (Date.now() - gameStart) - RENDER_DELAY;
}

// Returnează indexul actualizării de bază, prima actualizare a jocului înainte de
// timpul curent al serverului, sau -1 dacă N/A.
function getBaseUpdate() {
  const serverTime = currentServerTime();
  for (let i = gameUpdates.length - 1; i >= 0; i--) {
    if (gameUpdates[i].t <= serverTime) {
      return i;
    }
  }
  return -1;
}

Primul lucru de făcut este să ne dăm seama ce face currentServerTime(). Așa cum am văzut anterior, fiecare actualizare a jocului conține o marcă de timp a serverului. Vrem să folosim întârzierea de redare pentru a reda imaginea, întârziind față de server cu 100 ms, dar niciodată nu vom ști timpul curent de pe server, pentru că nu putem ști cât timp a durat să ajungă la noi oricare dintre actualizări. Internetul este imprevizibil și viteza acestuia poate varia foarte mult!

Pentru a depăși această problemă, putem folosi o aproximație rezonabilă: ne pretindem că prima actualizare a sosit instantaneu. Dacă ar fi adevărat, am ști timpul serverului în acel moment specific! Păstrăm marca de timp a serverului în firstServerTimestamp și salvăm marca noastră locală (client) în acel moment în gameStart.

O, stai puțin. Nu ar trebui să fie timpul pe server = timpul în client? De ce facem distincția între „marca de timp a serverului” și „marca de timp a clientului”? Este o întrebare excelentă! Se pare că nu sunt același lucru. Date.now() va returna mărci de timp diferite în client și server și aceasta depinde de factorii locali ai acestor mașini. Niciodată nu presupuneți că mărțile de timp vor fi aceleași pe toate mașinile.

Acum ne este clar ce face currentServerTime(): returnează marca de timp a serverului pentru timpul curent de redare. Cu alte cuvinte, acesta este timpul curent al serverului (firstServerTimestamp < + (Date.now() - gameStart)) minus întârzierea de redare (RENDER_DELAY).

Acum să vedem cum gestionăm actualizările jocului. Când primim o actualizare de la server, se apelează processGameUpdate(), și salvăm noua actualizare în array-ul gameUpdates. Apoi, pentru a verifica utilizarea memoriei, eliminăm toate actualizările vechi până la actualizarea de bază, deoarece nu mai avem nevoie de ele.

Ce este o «actualizare de bază»? Aceasta este prima actualizare pe care o găsim, mergând înapoi de la timpul curent al serverului. Țineți minte acest grafic?

Crearea unui joc web multiplayer în genul .io
Actualizarea jocului este imediat la stânga «Timp de redare client» și este actualizarea de bază.

De ce este utilizată actualizarea de bază? De ce putem ignora actualizările până la baza? Pentru a înțelege acest lucru, să în sfârșit examinăm implementarea getCurrentState():

state.js, partea 2

export function getCurrentState() {
  if (!firstServerTimestamp) {
    return {};
  }

  const base = getBaseUpdate();
  const serverTime = currentServerTime();

  // If base is the most recent update we have, use its state.
  // Else, interpolate between its state and the state of (base + 1).
  if (base < 0) {
    return gameUpdates[gameUpdates.length - 1];
  } else if (base === gameUpdates.length - 1) {
    return gameUpdates[base];
  } else {
    const baseUpdate = gameUpdates[base];
    const next = gameUpdates[base + 1];
    const r = (serverTime - baseUpdate.t) / (next.t - baseUpdate.t);
    return {
      me: interpolateObject(baseUpdate.me, next.me, r),
      others: interpolateObjectArray(baseUpdate.others, next.others, r),
      bullets: interpolateObjectArray(baseUpdate.bullets, next.bullets, r),
    };
  }
}

Gestionăm trei cazuri:

  1. base < 0 înseamnă că până la timpul curent de redare nu sunt actualizări (a se vedea implementarea de mai sus, getBaseUpdate()). Acest lucru se poate întâmpla chiar la începutul jocului din cauza întârzierii de redare. În acest caz, folosim cea mai recentă actualizare primită.
  2. base — aceasta este cea mai recentă actualizare pe care o avem. Acest lucru se poate întâmpla din cauza întârzierilor de rețea sau a unei conexiuni slabe la Internet. În acest caz, de asemenea, folosim cea mai recentă actualizare pe care o avem.
  3. Avem actualizări atât înainte, cât și după timpul curent de redare, deci este posibil să interpolăm!

Tot ce a mai rămas în state.js — este implementarea interpolării liniare, care este o matematică simplă (dar plictisitoare). Dacă doriți să o studiați singuri, deschideți state.js pe Github.

Partea 2. Server backend

În această parte, vom examina backend-ul Node.js, care gestionează exemplul nostru de joc .io.

1. Punctul de intrare al serverului

Pentru a gestiona serverul web, vom folosi un framework web popular pentru Node.js numit Express. Configurarea acestuia va fi realizată de fișierul nostru de punct de intrare al serverului src/server/server.js:

server.js, partea 1

const express = require('express');
const webpack = require('webpack');
const webpackDevMiddleware = require('webpack-dev-middleware');
const webpackConfig = require('../..//webpack.dev.js');

// Configurați un server Express
const app = express();
app.use(express.static('public'));

if (process.env.NODE_ENV === 'development') {
  // Configurați Webpack pentru dezvoltare
  const compiler = webpack(webpackConfig);
  app.use(webpackDevMiddleware(compiler));
} else {
  // Serviți static folderul dist/ în producție
  app.use(express.static('dist'));
}

// Ascultă pe port
const port = process.env.PORT || 3000;
const server = app.listen(port);
console.log(`Server ascultând pe portul ${port}`);

Amintiți-vă că în prima parte am discutat despre Webpack? Aici vom folosi configurațiile noastre Webpack. Le vom aplica în două moduri:

  • Utilizați webpack-dev-middleware pentru recompilarea automată a pachetelor noastre de dezvoltare, sau
  • A servi static folderul dist\/, în care Webpack va salva fișierele noastre după compilarea pentru producție.

O altă sarcină importantă server.js constă în configurarea serverului socket.io, care se conectează pur și simplu la serverul Express:

server.js, partea 2

const socketio = require('socket.io');
const Constants = require('../shared/constants');

// Configurați Express
// ...
const server = app.listen(port);
console.log(`Server ascultând pe portul ${port}`);

// Configurați socket.io
const io = socketio(server);

// Ascultă pentru conexiuni socket.io
io.on('connection', socket => {
  console.log('Jucător conectat!', socket.id);

  socket.on(Constants.MSG_TYPES.JOIN_GAME, joinGame);
  socket.on(Constants.MSG_TYPES.INPUT, handleInput);
  socket.on('disconnect', onDisconnect);
});

După stabilirea cu succes a conexiunii socket.io cu serverul, configurăm tratamentele pentru evenimentele pentru noul socket. Tratarea evenimentelor gestionează mesajele primite de la clienți delegându-le obiectului singleton game:

server.js, partea 3

const Game = require('./game');

// ...

// Configurați jocul
const game = new Game();

function joinGame(username) {
  game.addPlayer(this, username);
}

function handleInput(dir) {
  game.handleInput(this, dir);
}

function onDisconnect() {
  game.removePlayer(this);
}

Creăm un joc de tip .io, prin urmare, avem nevoie doar de o singură instanță Game ('Game') – toți jucătorii joacă pe aceeași arenă! În următoarea secțiune, vom vedea cum funcționează această clasă Game.

2. Game server-ului

Clasă Game conține cea mai importantă logică pe partea serverului. Are două sarcini principale: gestionarea jucătorilor și simularea jocului.

Să începem cu prima sarcină – gestionarea jucătorilor.

game.js, partea 1

const Constants = require('..\/shared\/constants');
const Player = require('.\/player');

class Game {
  constructor() {
    this.sockets = {};
    this.players = {};
    this.bullets = [];
    this.lastUpdateTime = Date.now();
    this.shouldSendUpdate = false;
    setInterval(this.update.bind(this), 1000\/60);
  }

  addPlayer(socket, username) {
    this.sockets[socket.id] = socket;

    \/\/ Generați o poziție pentru a începe acest jucător.
    const x = Constants.MAP_SIZE * (0.25 + Math.random() * 0.5);
    const y = Constants.MAP_SIZE * (0.25 + Math.random() * 0.5);
    this.players[socket.id] = new Player(socket.id, username, x, y);
  }

  removePlayer(socket) {
    delete this.sockets[socket.id];
    delete this.players[socket.id];
  }

  handleInput(socket, dir) {
    if (this.players[socket.id]) {
      this.players[socket.id].setDirection(dir);
    }
  }

  \/\/ ...
}

În acest joc, vom identifica jucătorii după câmpul id sockets-ului socket.io (dacă te-ai pierdut, întoarce-te la server.js). Socket.io alocă de la sine fiecărui socket un id, așa că nu trebuie să ne facem griji în legătură cu asta. Îl voi numi ID-ul jucătorului.

Amintindu-ți asta, să examinăm variabilele de instanță din clasa Game:

  • sockets — acesta este un obiect care leagă ID-ul jucătorului de socket-ul asociat cu jucătorul. Permite accesarea socket-urilor în timp constant folosind ID-urile jucătorilor.
  • players — acesta este un obiect care leagă ID-ul jucătorului de obiectul code>Player

bullets — acesta este un array de obiecte Bullet, fără un anumit ordin.
lastUpdateTime — acesta este un timestamp al ultimei actualizări a jocului. În curând vom vedea cum este folosit.
shouldSendUpdate — aceasta este o variabilă auxiliară. Vom vedea și utilizarea ei în curând.
Metode addPlayer(), removePlayer() și handleInput() nu trebuie explicate, ele sunt folosite în server.js. Dacă ai nevoie să îți reîmprospătezi memoria, întoarce-te puțin mai sus.

Ultima linie constructor() pornește ciclul de actualizare al jocului (cu o frecvență de 60 de actualizări/sec):

game.js, partea 2

const Constants = require('..\/shared\/constants');
const applyCollisions = require('.\/collisions');

class Game {
  \/\/ ...

  update() {
    \/\/ Calculează timpul scurs
    const now = Date.now();
    const dt = (now - this.lastUpdateTime) \/ 1000;
    this.lastUpdateTime = now;

    \/\/ Actualizează fiecare proiectil
    const bulletsToRemove = [];
    this.bullets.forEach(bullet => {
      if (bullet.update(dt)) {
        \/\/ Distruge acest proiectil
        bulletsToRemove.push(bullet);
      }
    });
    this.bullets = this.bullets.filter(
      bullet => !bulletsToRemove.includes(bullet),
    );

    \/\/ Actualizează fiecare jucător
    Object.keys(this.sockets).forEach(playerID => {
      const player = this.players[playerID];
      const newBullet = player.update(dt);
      if (newBullet) {
        this.bullets.push(newBullet);
      }
    });

    \/\/ Aplică coliziunile, oferă jucătorilor puncte pentru lovirea proiectilelor
    const destroyedBullets = applyCollisions(
      Object.values(this.players),
      this.bullets,
    );
    destroyedBullets.forEach(b => {
      if (this.players[b.parentID]) {
        this.players[b.parentID].onDealtDamage();
      }
    });
    this.bullets = this.bullets.filter(
      bullet => !destroyedBullets.includes(bullet),
    );

    \/\/ Verifică dacă există jucători morți
    Object.keys(this.sockets).forEach(playerID => {
      const socket = this.sockets[playerID];
      const player = this.players[playerID];
      if (player.hp  {
        const socket = this.sockets[playerID];
        const player = this.players[playerID];
        socket.emit(
          Constants.MSG_TYPES.GAME_UPDATE,
          this.createUpdate(player, leaderboard),
        );
      });
      this.shouldSendUpdate = false;
    } else {
      this.shouldSendUpdate = true;
    }
  }

  \/\/ ...
}

Metoda update() conține, probabil, cea mai importantă parte a logicii pe partea de server. Să enumerăm în ordine tot ce face:

  1. Calculează cât timp dt a trecut de la ultima update().
  2. Actualizează fiecare proiectil și, dacă este necesar, le distruge. Implementarea acestei funcționalități o vom vedea mai târziu. Deocamdată, e suficient să știm că bullet.update() întoarce true, dacă proiectilul trebuie distrus (a ieșit din limitele arenei).
  3. Actualizează fiecare jucător și, dacă este necesar, creăm un proiectil. Această implementare o vom vedea și mai târziu — player.update() poate returna un obiect Bullet.
  4. Verifică coliziunile între proiectile și jucători folosind applyCollisions(), care returnează un array de proiectile care au lovit jucătorii. Pentru fiecare proiectil returnat, vom crește scorul jucătorului care l-a lansat (folosind player.onDealtDamage()), și apoi eliminăm proiectilul din array bullets.
  5. Notifică și distruge toți jucătorii uciși.
  6. Trimite tuturor jucătorilor o actualizare a jocului fiecare a doua oară la apelul update(). Aceasta ne ajută să urmărim variabila auxiliară menționată anterior shouldSendUpdate. Deoarece update() apelează de 60 de ori pe secundă, trimitem actualizări ale jocului de 30 de ori pe secundă. Astfel, frequenta de ceas a serverului este de 30 de impulsuri pe secundă (am discutat despre frecvența ceasului în prima parte).

De ce să trimitem actualizări ale jocului doar odată la două ? Pentru a economisi banda. 30 de actualizări ale jocului pe secundă sunt foarte multe!

De ce nu am apela update() de 30 de ori pe secundă? Pentru a îmbunătăți simularea jocului. Cu cât apelul este mai frecvent, update(), cu atât simularea jocului va fi mai precisă. Dar nu trebuie să te lase prea mult absorbit de numărul apelurilor, update()deoarece această sarcină este costisitoare din punct de vedere computațional—60 pe secundă este mai mult decât suficient.

Partea rămasă a clasei Game constă din metodele auxiliare utilizate în update():

game.js, partea a 3-a

class Game {
  // ...

  getLeaderboard() {
    return Object.values(this.players)
      .sort((p1, p2) => p2.score - p1.score)
      .slice(0, 5)
      .map(p => ({ username: p.username, score: Math.round(p.score) }));
  }

  createUpdate(player, leaderboard) {
    const nearbyPlayers = Object.values(this.players).filter(
      p => p !== player && p.distanceTo(player)  b.distanceTo(player)  p.serializeForUpdate()),
      bullets: nearbyBullets.map(b => b.serializeForUpdate()),
      leaderboard,
    };
  }
}

getLeaderboard() este destul de simplu – sortează jucătorii după punctaj, ia cei mai buni cinci și returnează pentru fiecare numele de utilizator și scorul.

createUpdate() este utilizat în update() pentru a crea actualizări ale jocului care sunt transmise jucătorilor. Sarcina principală a sa este de a apela metodele serializeForUpdate(), implementate pentru clasele Player. și BulletObservați că acesta trimite fiecărui jucător date doar despre jucătorii din apropiere și proiectile – nu este necesar să trimiți informații despre obiectele de joc care sunt departe de jucător!

3. Obiectele de joc pe server

În jocul nostru, proiectilele și jucătorii sunt în realitate foarte asemănători: sunt obiecte de joc mobile, rotunde și abstracte. Pentru a beneficia de această similaritate între jucători și proiectile, haideți să începem prin implementarea unei clase de bază Object:

object.js

class Object {
  constructor(id, x, y, dir, speed) {
    this.id = id;
    this.x = x;
    this.y = y;
    this.direction = dir;
    this.speed = speed;
  }

  update(dt) {
    this.x += dt * this.speed * Math.sin(this.direction);
    this.y -= dt * this.speed * Math.cos(this.direction);
  }

  distanceTo(object) {
    const dx = this.x - object.x;
    const dy = this.y - object.y;
    return Math.sqrt(dx * dx + dy * dy);
  }

  setDirection(dir) {
    this.direction = dir;
  }

  serializeForUpdate() {
    return {
      id: this.id,
      x: this.x,
      y: this.y,
    };
  }
}

Aici nu se întâmplă nimic complicat. Această clasă va fi un bun punct de plecare pentru extindere. Să vedem cum se dezvoltă clasa Bullet folosește Object:

bullet.js

const shortid = require('shortid');
const ObjectClass = require('./object');
const Constants = require('../shared/constants');

class Bullet extends ObjectClass {
  constructor(parentID, x, y, dir) {
    super(shortid(), x, y, dir, Constants.BULLET_SPEED);
    this.parentID = parentID;
  }

  // Returnează true dacă glonțul trebuie distrus
  update(dt) {
    super.update(dt);
    return this.x  Constants.MAP_SIZE || this.y  Constants.MAP_SIZE;
  }
}

Implementarea Bullet foarte scurt! Am adăugat la Object doar următoarele extensii:

  • Utilizarea pachetului shortid pentru generare aleatorie id a glonțului.
  • Adăugarea câmpului parentID, pentru a putea urmări jucătorul care a creat acest glonț.
  • Adăugarea valorii de retur în update(), care este egal cu true, dacă glonțul se află în afara arenei (îți amintești, am discutat despre asta în secțiunea anterioară?).

Să trecem la Player.:

player.js

const ObjectClass = require('./object');
const Bullet = require('./bullet');
const Constants = require('../shared/constants');

class Player extends ObjectClass {
  constructor(id, username, x, y) {
    super(id, x, y, Math.random() * 2 * Math.PI, Constants.PLAYER_SPEED);
    this.username = username;
    this.hp = Constants.PLAYER_MAX_HP;
    this.fireCooldown = 0;
    this.score = 0;
  }

  // Returnează un glonț nou creat, sau null.
  update(dt) {
    super.update(dt);

    // Actualizează scorul
    this.score += dt * Constants.SCORE_PER_SECOND;

    // Asigură-te că jucătorul rămâne în limite
    this.x = Math.max(0, Math.min(Constants.MAP_SIZE, this.x));
    this.y = Math.max(0, Math.min(Constants.MAP_SIZE, this.y));

    // Fă să tragă un glonț, dacă este necesar
    this.fireCooldown -= dt;
    if (this.fireCooldown <= 0) {
      this.fireCooldown += Constants.PLAYER_FIRE_COOLDOWN;
      return new Bullet(this.id, this.x, this.y, this.direction);
    }
    return null;
  }

  takeBulletDamage() {
    this.hp -= Constants.BULLET_DAMAGE;
  }

  onDealtDamage() {
    this.score += Constants.SCORE_BULLET_HIT;
  }

  serializeForUpdate() {
    return {
      ...(super.serializeForUpdate()),
      direction: this.direction,
      hp: this.hp,
    };
  }
}

Jucătorii sunt mai complecși decât glonțele, așa că în această clasă ar trebui să se stocheze câteva câmpuri suplimentare. Metoda sa update() face o muncă mai mare, în special, returnează glonțul proaspăt creat, dacă nu a rămas fireCooldown îți amintești, am discutat despre asta în secțiunea anterioară?). De asemenea, extinde metoda serializeForUpdate(), pentru că trebuie să includem în actualizarea jocului câmpuri suplimentare pentru jucător.

Existența unei clase de bază Object este un pas important pentru a evita repetarea codului. De exemplu, fără această clasă Object fiecare obiect de joc ar trebui să aibă aceeași implementare distanceTo(), și sincronizarea copiat-arată a tuturor acestor implementări în mai multe fișiere ar fi un coșmar. Aceasta devine cu atât mai importantă pentru proiectele mari, când numărul de clase extinse crește. Object 4. Recunoașterea coliziunilor

4. Recunoașterea coliziunilor

Singurul lucru care ne-a mai rămas este să recunoaștem când proiectilele lovesc jucătorii! Amintiți-vă acest fragment de cod din metoda update() din clasa Game:

game.js

const applyCollisions = require('./collisions');

class Game {
  // ...

  update() {
    // ...

    // Aplică coliziunile, oferind jucătorilor puncte pentru lovirea proiectilelor
    const destroyedBullets = applyCollisions(
      Object.values(this.players),
      this.bullets,
    );
    destroyedBullets.forEach(b => {
      if (this.players[b.parentID]) {
        this.players[b.parentID].onDealtDamage();
      }
    });
    this.bullets = this.bullets.filter(
      bullet => !destroyedBullets.includes(bullet),
    );

    // ...
  }
}

Trebuie să implementăm metoda applyCollisions(), care returnează toate proiectilele care au lovit jucătorii. Din fericire, nu este prea complicat de realizat, deoarece

  • Toate obiectele care colizionează sunt cercuri, iar aceasta este cea mai simplă formă de recunoaștere a coliziunilor.
  • Avem deja metoda distanceTo(), pe care am implementat-o în secțiunea anterioară a clasei Object.

Iată cum arată implementarea noastră a recunoașterii coliziunilor:

collisions.js

const Constants = require('../shared/constants');

// Returns an array of bullets to be destroyed.
function applyCollisions(players, bullets) {
  const destroyedBullets = [];
  for (let i = 0; i < bullets.length; i++) {
    // Look for a player (who didn't create the bullet) to collide each bullet with.
    // As soon as we find one, break out of the loop to prevent double counting a bullet.
    for (let j = 0; j < players.length; j++) {
      const bullet = bullets[i];
      const player = players[j];
      if (
        bullet.parentID !== player.id &&
        player.distanceTo(bullet) <= Constants.PLAYER_RADIUS + Constants.BULLET_RADIUS
      ) {
        destroyedBullets.push(bullet);
        player.takeBulletDamage();
        break;
      }
    }
  }
  return destroyedBullets;
}

Această recunoaștere simplă a coliziunilor se bazează pe faptul că două cercuri colizionează dacă distanța dintre centrele lor este mai mică decât suma razelor lor. Iată un caz în care distanța dintre centrele celor două cercuri este exact egală cu suma razelor lor:

Crearea unui joc web multiplayer în genul .io
Aici trebuie să fim atenți la câteva aspecte:

  • Proiectilul nu trebuie să lovească jucătorul care l-a creat. Acest lucru poate fi realizat comparând bullet.parentID de player.id.
  • Proiectilul trebuie să lovească doar o singură dată în cazul unei coliziuni simultane cu mai mulți jucători. Această problemă o vom rezolva folosind operatorul break: de îndată ce găsim un jucător care a colizionat cu proiectilul, oprind căutarea și trecând la următorul proiectil.

Sfârșit

Și asta e tot! Am acoperit tot ce trebuie să știți pentru a crea un joc pe web de tip .io. Ce urmează? Creează-ți propriul joc .io!

Întregul cod al exemplului are sursa deschisă și este disponibil pe Github.

Sursa: habr.com

Cumpără un hosting fiabil pentru site-uri cu protecție DDoS, servere VPS VDS 🔥 Cumpără un hosting fiabil pentru site-uri cu protecție DDoS, servere VPS VDS | ProHoster