Creazione di un gioco web multiplayer nel genere .io

Creazione di un gioco web multiplayer nel genere .io
Uscita nel 2015 Agar.io è stata la precorritrice di un nuovo genere di giochi .io, la cui popolarità è notevolmente aumentata da allora. Ho sperimentato la crescita della popolarità dei giochi .io in prima persona: negli ultimi tre anni ho creato e venduto due giochi di questo genere..

Nel caso in cui non abbiate mai sentito parlare di questi giochi: si tratta di giochi web multiplayer gratuiti, ai quali è facile partecipare (non è necessario un account). Di solito, mettono contro molti giocatori su un'unica arena. Altri giochi famosi del genere .io: Slither.io e Diep.io.

In questo post esploreremo come creare un gioco .io da zero. Per questo vi basterà conoscere Javascript: dovete capire concetti come la sintassi ES6, la parola chiave this e Promises. Anche se non conoscete perfettamente Javascript, sarete comunque in grado di comprendere gran parte del post.

Esempio di gioco .io

Per facilitarvi l'apprendimento faremo riferimento a un esempio di gioco .io. Provate a giocarci!

Creazione di un gioco web multiplayer nel genere .io
Il gioco è piuttosto semplice: controllate una nave nell'arena, dove ci sono altri giocatori. La vostra nave spara automaticamente proiettili e cercate di colpire gli altri giocatori, mentre evitate i loro proiettili.

1. Panoramica e struttura del progetto

Consiglio scaricare il codice sorgente del esempio di gioco, così puoi seguirmi.

L'esempio utilizza quanto segue:

  • Express — il framework web più popolare per Node.js, che gestisce il server web del gioco.
  • socket.io — una libreria websocket per scambiare dati tra browser e server.
  • Webpack — un gestore di moduli. Puoi leggere perché utilizzare Webpack qui.

Ecco come appare la struttura della directory del progetto:

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

public/

Tutto nella cartella public/ sarà servito staticamente dal server. In public/assets/ ci sono le immagini utilizzate dal nostro progetto.

src/

Tutto il codice sorgente si trova nella cartella src/. Nomi client/ e server/ parlano da soli, e shared/ contiene un file di costanti, importato sia dal client che dal server.

2. Build / parametri del progetto

Come detto in precedenza, per il build del progetto utilizziamo il gestore di moduli Webpack. Diamo un'occhiata alla nostra configurazione di 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',
    }),
  ],
};

Le righe più importanti qui sono le seguenti:

  • src/client/index.js — è il punto di ingresso del client Javascript (JS). Webpack inizierà da qui e cercherà ricorsivamente altri file importati.
  • Il JS di output della nostra build Webpack si troverà nella directory dist/. Chiamerò questo file il nostro pacchetto JS.
  • Utilizziamo Babel, e in particolare la configurazione @babel/preset-env per transpillare il nostro codice JS per i browser più vecchi.
  • Utilizziamo un plugin per estrarre tutti i CSS a cui fanno riferimento i file JS e per combinarli in un unico luogo. Lo chiamerò il nostro pacchetto CSS.

Potresti aver notato nomi di file dei pacchetti strani '[name].[contenthash].ext'. Contengono sostituzioni dei nomi dei file Webpack: [name] verrà sostituito con il nome del punto di ingresso (nel nostro caso è game), e [contenthash] verrà sostituito con l'hash del contenuto del file. Lo facciamo per ottimizzare il progetto per l'hashing — possiamo dire ai browser di memorizzare nella cache i nostri pacchetti JS all'infinito, perché se un pacchetto cambia, cambia anche il nome del suo file (cambia contenthash). Il risultato finale sarà un nome di file del tipo game.dbeee76e91a97d0c7207.js.

File webpack.common.js — questo è il file di configurazione di base che importiamo nelle configurazioni di sviluppo e del progetto finale. Ecco, ad esempio, la configurazione di sviluppo:

webpack.dev.js

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

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

Per efficienza, utilizziamo nel processo di sviluppo webpack.dev.js, e si passa a webpack.prod.js, per ottimizzare le dimensioni dei pacchetti durante il deployment in produzione.

Impostazione locale

Ti consiglio di installare il progetto sulla macchina locale, così potrai seguire i passaggi elencati in questo post. L'impostazione è semplice: prima di tutto, devono essere installati nel sistema Node e NPM. Successivamente, bisogna eseguire

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

e sei pronto per iniziare! Per avviare il server di sviluppo, è sufficiente eseguire

$ npm run develop

e accedere nel browser a localhost:3000. Il server di sviluppo ricompilerà automaticamente i pacchetti JS e CSS durante la modifica del codice: basta aggiornare la pagina per vedere tutte le modifiche!

3. Punti di accesso del client

Iniziamo con il codice del gioco. Prima di tutto, avremo bisogno di una pagina index.html, che il browser caricherà per prima visitando il sito. La nostra pagina sarà piuttosto semplice:

index.html

<!DOCTYPE html>
<html>
<head>
  <title>Un gioco di esempio .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="Nome utente" />
    <button id="play-button">GIOCA</button>
  </div>
</body>
</html>

Questo esempio di codice è leggermente semplificato per chiarezza; lo stesso farò con molti altri esempi del post. Il codice completo è sempre visibile su Github.

Abbiamo:

  • Elemento HTML5 Canvas (<canvas>), che utilizzeremo per il rendering del gioco.
  • <link> per aggiungere il nostro pacchetto CSS.
  • <script> per aggiungere il nostro pacchetto Javascript.
  • Menu principale con nome utente <input> e un pulsante «PLAY» (

Una volta caricata la homepage, il codice Javascript inizierà ad essere eseguito, partendo dal file JS di ingresso: 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);
  };
});

Può sembrare complicato, ma in realtà qui non ci sono molte azioni in corso:

  1. Importazione di altri file JS.
  2. Importazione del CSS (in modo che Webpack sappia di includerli nel nostro pacchetto CSS).
  3. Avvio connect() per stabilire una connessione con il server e avviare downloadAssets() per scaricare le immagini necessarie al rendering del gioco.
  4. Dopo il completamento della fase 3 viene visualizzato il menu principale (playMenu).
  5. Impostazione del gestore di eventi per il pulsante 'PLAY'. Quando il pulsante viene premuto, il codice inizializza il gioco e informa il server che siamo pronti a giocare.

La parte fondamentale della nostra logica client-server si trova nei file che sono stati importati dal file index.js. Ora esamineremo tutti in ordine.

4. Scambio di dati del cliente

In questo gioco utilizziamo una biblioteca ben nota per comunicare con il server. socket.io. In Socket.io c'è supporto integrato WebSockets, che è ideale per la comunicazione bidirezionale: possiamo inviare messaggi al server e e il server può inviare messaggi a noi attraverso la stessa connessione.

Avremo un file src/client/networking.js, che si occuperà di tutte le comunicazioni con il server:

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('Connesso al server!');
    resolve();
  });
});

export const connect = onGameOver => (
  connectedPromise.then(() => {
    // Registra i callback
    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);
};

Questo codice è stato anche leggermente abbreviato per chiarezza.

In questo file avvengono tre azioni principali:

  • Tentiamo di connetterci al server. connectedPromise viene risolto solo quando abbiamo stabilito la connessione.
  • Se la connessione è stabilita correttamente, registriamo le funzioni di callback (processGameUpdate() e onGameOver()) per i messaggi che possiamo ricevere dal server.
  • Esportiamo play() e updateDirection(), affinché possano essere utilizzati da altri file.

5. Rendering del client

È tempo di visualizzare l'immagine sullo schermo!

…ma prima di poterlo fare, dobbiamo scaricare tutte le immagini (risorse) necessarie. Scriviamo un gestore delle risorse:

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(`Scaricato ${assetName}`);
      assets[assetName] = asset;
      resolve();
    };
    asset.src = `/assets/${assetName}`;
  });
}

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

Gestire le risorse non è così difficile! La cosa principale è mantenere un oggetto assets, che collegherà la chiave del nome del file al valore dell'oggetto Image. Quando la risorsa è caricata, la salviamo nell'oggetto assets per un accesso rapido in futuro. Quando viene consentito il download di ciascuna risorsa (cioè quando le все risorse sono state caricate), autorizziamo downloadPromise.

Una volta scaricati le risorse, puoi iniziare il rendering. Come detto in precedenza, per disegnare sulla pagina web utilizziamo HTML5 Canvas (<canvas>). Il nostro gioco è abbastanza semplice, quindi è sufficiente disegnare solo il seguente:

  1. Sfondo
  2. Nave del giocatore
  3. Altri giocatori presenti nel gioco
  4. Proiettili

Ecco i frammenti importanti src/client/render.js, che disegnano esattamente i quattro punti sopra elencati:

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;

// Ottieni il contesto grafico del canvas
const canvas = document.getElementById('game-canvas');
const context = canvas.getContext('2d');

// Rendi il canvas a schermo intero
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;

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

  // Disegna lo sfondo
  renderBackground(me.x, me.y);

  // Disegna tutti i proiettili
  bullets.forEach(renderBullet.bind(null, me));

  // Disegna tutti i giocatori
  renderPlayer(me, me);
  others.forEach(renderPlayer.bind(null, me));
}

// ... Funzioni di supporto qui escluse

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

Questo codice è anche abbreviato per chiarezza.

render() è la funzione principale di questo file. startRendering() e stopRendering() controllano l'attivazione del ciclo di rendering a 60 FPS.

Le implementazioni specifiche di singole funzioni ausiliarie di rendering (ad esempio renderBullet()) non sono così importanti, ma ecco un semplice esempio:

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

Nota che utilizziamo il metodo getAsset(), che abbiamo già visto in asset.js!

Se sei interessato a esplorare altre funzioni ausiliarie di rendering, leggi il resto di src/client/render.js.

6. Input del cliente

È tempo di rendere il gioco giocabile! Lo schema di controllo sarà molto semplice: per cambiare direzione puoi usare il mouse (sul computer) o il tocco dello schermo (sul dispositivo mobile). Per realizzarlo, registreremo Event Listeners per gli eventi Mouse e Touch.
Tutto questo sarà gestito da 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() e onTouchInput() — sono Event Listeners che invocano updateDirection() (da networking.js) quando si verifica un evento di input (ad esempio, spostando il mouse). updateDirection() gestisce la comunicazione con il server, che processa l'evento di input e aggiorna di conseguenza lo stato del gioco.

7. Lo stato del client

Questa sezione è la più complessa della prima parte del post. Non preoccuparti se non la capisci al primo tentativo! Puoi anche saltarla e tornarci più tardi.

L'ultimo pezzo del puzzle necessario per completare il codice client-server è state. Ricordi il frammento di codice della sezione "Rendering del client"?

render.js

import { getCurrentState } from './state';

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

  // Esegui il rendering
  // ...
}

getCurrentState() deve essere in grado di fornirci lo stato attuale del gioco nel client in qualsiasi momento sulla base degli aggiornamenti ricevuti dal server. Ecco un esempio di aggiornamento del gioco che potrebbe inviare il server:

{
  "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
    }
  ]
}

Ogni aggiornamento del gioco contiene cinque campi identici:

  • t: timestamp del server, che indica il momento in cui è stato creato questo aggiornamento.
  • me: informazioni sul giocatore che riceve questo aggiornamento.
  • others: array di informazioni su altri giocatori coinvolti nello stesso gioco.
  • bullets: array di informazioni sui proiettili nel gioco.
  • leaderboard: dati attuali della classifica. In questo post, non li considereremo.

7.1 Stato naive del client

Implementazione naive getCurrentState() può solo restituire direttamente i dati dell'ultimo aggiornamento del gioco ricevuto.

naive-state.js

let lastGameUpdate = null;

// Gestire un aggiornamento di gioco ricevuto di recente.
export function processGameUpdate(update) {
  lastGameUpdate = update;
}

export function getCurrentState() {
  return lastGameUpdate;
}

Bello e chiaro! Ma se solo fosse così semplice. Una delle ragioni per cui tale implementazione è problematica: limita la frequenza dei fotogrammi di rendering alla frequenza dell'orologio del server.

Frequenza dei fotogrammi (Frame Rate): numero di fotogrammi (cioè chiamate render()) al secondo, o FPS. Nei giochi si cerca normalmente di raggiungere almeno 60 FPS.

Frequenza di tick (Tick Rate): la frequenza con cui il server invia aggiornamenti di gioco ai client. Spesso è inferiore alla frequenza dei fotogrammi. Nel nostro gioco, il server opera a 30 tick al secondo.

Se ci limitiamo a renderizzare l'ultimo aggiornamento del gioco, la FPS in sostanza non potrà mai superare 30, perché non riceviamo mai più di 30 aggiornamenti dal server al secondo. Anche se chiamiamo render() 60 volte al secondo, la metà di queste chiamate semplicemente ridisegnerà la stessa cosa, in sostanza non facendo nulla. Un altro problema dell'implementazione naïve è che è soggetta a ritardiCon una connessione Internet ideale, il cliente riceverà l'aggiornamento di gioco esattamente ogni 33 ms (30 al secondo):

Creazione di un gioco web multiplayer nel genere .io
Purtroppo, nulla è perfetto. Un quadro più realistico sarebbe il seguente:
Creazione di un gioco web multiplayer nel genere .io
Un'implementazione ingenua rappresenta praticamente il caso peggiore in termini di latenza. Se l'aggiornamento di gioco viene ricevuto con un ritardo di 50 ms, allora il cliente subisce ulteriori 50 ms di ritardo, poiché continua a renderizzare lo stato di gioco dall'aggiornamento precedente. Potete immaginare quanto sia scomodo per il giocatore: a causa di ritardi arbitrari, il gioco apparirà scattoso e instabile.

7.2 Stato del cliente migliorato

Apporteremo alcune migliorie all'implementazione ingenua. Innanzitutto, utilizziamo un ritardo di rendering di 100 ms. Ciò significa che lo stato "attuale" del cliente sarà sempre indietro rispetto allo stato di gioco sul server di 100 ms. Ad esempio, se sul server il tempo è 150, allora sul cliente verrà renderizzato lo stato in cui si trovava il server al momento di 50:

Creazione di un gioco web multiplayer nel genere .io
Questo ci fornisce un buffer di 100 ms, permettendo di gestire tempi di ricezione imprevedibili degli aggiornamenti di gioco:

Creazione di un gioco web multiplayer nel genere .io
Il prezzo da pagare per questo sarà un costante ritardo di input a 100 ms. È un sacrificio minimo per un’esperienza di gioco fluida — la maggior parte dei giocatori (soprattutto quelli occasionali) non notarà nemmeno questo ritardo. È molto più facile per le persone adattarsi a un ritardo costante di 100 ms che giocare con un ritardo imprevedibile.

Possiamo utilizzare anche un'altra tecnica chiamata «previsione lato client», che riduce efficacemente le latenze percepite, ma non sarà trattata in questo post.

Un altro miglioramento che utilizziamo è l'interpolazione lineare. A causa del ritardo nel rendering, generalmente siamo almeno un aggiornamento in anticipo rispetto al tempo attuale nel client. Quando viene chiamato getCurrentState(), possiamo eseguire l'interpolazione lineare tra gli aggiornamenti del gioco immediatamente prima e dopo il tempo attuale nel client:

Creazione di un gioco web multiplayer nel genere .io
Questo risolve il problema del frame rate: ora possiamo rendere fotogrammi unici con qualsiasi frequenza ci sia necessaria!

7.3 Implementazione di uno stato client migliorato

Un esempio di implementazione in src/client/state.js utilizza sia il ritardo di rendering che l'interpolazione lineare, ma è solo temporaneo. Suddividiamo il codice in due parti. Ecco la prima:

state.js, parte 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);

  // Keep only one game update before the current server time
  const base = getBaseUpdate();
  if (base > 0) {
    gameUpdates.splice(0, base);
  }
}

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

// Returns the index of the base update, the first game update before
// current server time, or -1 if 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;
}

Iniziamo col capire cosa fa currentServerTime(). Come abbiamo visto prima, ad ogni aggiornamento di gioco è inclusa una timestamp del server. Vogliamo usare il ritardo di rendering per visualizzare l'immagine con un ritardo rispetto al server di 100 ms, ma non sapremo mai qual è l'ora attuale sul server, perché non possiamo sapere quanto tempo ci sia voluto per ricevere ciascuno degli aggiornamenti. Internet è imprevedibile e la sua velocità può variare drasticamente!

Per affrontare questo problema, possiamo usare una ragionevole approssimazione: faremo finta che il primo aggiornamento sia arrivato istantaneamente fingiamo che il primo aggiornamento sia arrivato immediatamente. Se questo fosse vero, conosceremmo il tempo del server in quel momento specifico! Salviamo il timestamp del server in firstServerTimestamp e salviamo il nostro locale (client) timestamp nello stesso momento in gameStart.

Oh, aspetta. Non dovrebbe essere che il tempo del server = il tempo nel client? Perché facciamo distinzione tra “timestamp del server” e “timestamp del client”? È un'ottima domanda! A quanto pare, non sono la stessa cosa. Date.now() restituirà timestamp diversi nel client e nel server, e questo dipende da fattori locali per quelle macchine. Non daremai per scontato che i timestamp siano gli stessi su tutte le macchine.

Ora è chiaro cosa fa currentServerTime(): restituisce il timestamp del server all'ora corrente di rendering. In altre parole, è l'ora attuale del server (firstServerTimestamp <+ (Date.now() - gameStart)) meno il ritardo di rendering (RENDER_DELAY).

Ora vediamo come gestiamo gli aggiornamenti del gioco. Quando riceviamo aggiornamenti dal server, viene chiamato processGameUpdate(), e salviamo il nuovo aggiornamento nell'array gameUpdates. Poi, per controllare l'uso della memoria, rimuoviamo tutte le vecchie aggiornamenti fino a all'aggiornamento base, perché non ci servono più.

Che cos'è un "aggiornamento di base"? È il primo aggiornamento che troviamo, risalendo nel tempo rispetto all'attuale ora del server. Ricordi questo schema?

Creazione di un gioco web multiplayer nel genere .io
L'aggiornamento del gioco si trova direttamente a sinistra di "Client Render Time" ed è l'aggiornamento di base.

A cosa serve un aggiornamento di base? Perché possiamo scartare aggiornamenti fino a quello di base? Per chiarire tutto questo, vediamo finalmente l'implementazione getCurrentState():

state.js, parte 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),
    };
  }
}

Gestiamo tre casi:

  1. base < 0 significa che non ci sono aggiornamenti fino all'attuale ora di rendering (vedi sopra l'implementazione di getBaseUpdate()). Questo può accadere subito all'inizio del gioco a causa di ritardi nel rendering. In tal caso, utilizziamo l'ultimo aggiornamento ricevuto.
  2. base è l'ultimo aggiornamento che abbiamo. Questo può accadere a causa di ritardi di rete o di una scarsa connessione a Internet. In questo caso, usiamo anche l'ultimo aggiornamento che abbiamo.
  3. Abbiamo aggiornamenti sia prima che dopo l'attuale ora di rendering, quindi possiamo interpolare!

Tutto ciò che rimane in state.js — è un'implementazione dell'interpolazione lineare, che consiste in una matematica semplice (ma noiosa). Se vuoi studiarla da solo, apri state.js con Github.

Parte 2. Server backend

In questa parte esamineremo il backend Node.js che gestisce il nostro esempio di gioco .io.

1. Punto di ingresso del server

Per gestire il server web useremo un popolare framework web per Node.js chiamato Express. La sua configurazione sarà gestita dal nostro file di punto di ingresso del server src/server/server.js:

server.js, parte 1

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

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

if (process.env.NODE_ENV === 'development') {
  // Configura Webpack per lo sviluppo
  const compiler = webpack(webpackConfig);
  app.use(webpackDevMiddleware(compiler));
} else {
  // Servi staticamente la cartella dist/ in produzione
  app.use(express.static('dist'));
}

// Ascolta sulla porta
const port = process.env.PORT || 3000;
const server = app.listen(port);
console.log(`Server in ascolto sulla porta ${port}`);

Ricorda che nella prima parte abbiamo discusso di Webpack? Proprio qui useremo le nostre configurazioni di Webpack. Le applicheremo in due modi:

  • Usa webpack-dev-middleware per la ricompilazione automatica dei nostri pacchetti di sviluppo, oppure
  • Servire staticamente la cartella dist/, in cui Webpack registrerà i nostri file dopo la compilazione della produzione.

Un'altra importante attività server.js è configurare il server socket.io, che si collega semplicemente al server Express:

server.js, parte 2

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

// Configura Express
// ...
const server = app.listen(port);
console.log(`Server in ascolto sulla porta ${port}`);

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

// Ascolta le connessioni socket.io
io.on('connection', socket => {
  console.log('Giocatore connesso!', socket.id);

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

Dopo aver stabilito con successo la connessione socket.io con il server, configuriamo i gestori degli eventi per il nuovo socket. I gestori degli eventi elaborano i messaggi ricevuti dai client delegando all'oggetto singleton game:

server.js, parte 3

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

// ...

// Configura il Gioco
const game = new Game();

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

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

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

Stiamo creando un gioco di tipo .io, quindi avremo bisogno di solo un'istanza di Game («Game») – tutti i giocatori giocano su un'unica arena! Nella prossima sezione vedremo come funziona questa classe Game.

2. Game del server

Classe Game contiene la logica più importante lato server. Ha due compiti principali: gestione dei giocatori e simulazione del gioco.

Iniziamo con il primo compito: la gestione dei giocatori.

game.js, parte 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 una posizione per iniziare questo giocatore.
    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);
    }
  }

  // ...
}

In questo gioco identificheremo i giocatori tramite il campo id del loro socket socket.io (se sei confuso, torna a server.js). Socket.io assegna a ciascun socket un identificativo unico id, quindi non dobbiamo preoccuparci di questo. Lo chiamerò ID del giocatore.

Tenendo a mente questo, esaminiamo le variabili di istanza nella classe Game:

  • sockets — è un oggetto che associa l'ID del giocatore al socket collegato a quel giocatore. Ci permette di accedere ai socket in tempo costante tramite i loro ID giocatore.
  • giocatori — è un oggetto che associa l'ID del giocatore all'oggetto code>Player

bullets — è un array di oggetti Bullet, che non ha un ordine definito.
lastUpdateTime — è un timestamp che indica l'ultimo aggiornamento del gioco. Presto vedremo come viene utilizzato.
shouldSendUpdate — è una variabile ausiliaria. Anche il suo utilizzo lo vedremo presto.
I metodi addPlayer(), removePlayer() e handleInput() non necessita spiegazioni, sono usati in server.js. Se hai bisogno di rinfrescarti la memoria, torna un po' più su.

L'ultima riga constructor() avvia il ciclo di aggiornamento del gioco (a una frequenza di 60 aggiornamenti/s):

game.js, parte 2

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

class Game {
  // ...

  update() {
    // Calcola il tempo trascorso
    const now = Date.now();
    const dt = (now - this.lastUpdateTime) / 1000;
    this.lastUpdateTime = now;

    // Aggiorna ogni proiettile
    const bulletsToRemove = [];
    this.bullets.forEach(bullet => {
      if (bullet.update(dt)) {
        // Distruggi questo proiettile
        bulletsToRemove.push(bullet);
      }
    });
    this.bullets = this.bullets.filter(
      bullet => !bulletsToRemove.includes(bullet),
    );

    // Aggiorna ogni giocatore
    Object.keys(this.sockets).forEach(playerID => {
      const player = this.players[playerID];
      const newBullet = player.update(dt);
      if (newBullet) {
        this.bullets.push(newBullet);
      }
    });

    // Applica le collisioni, assegna punteggio ai giocatori per aver colpito i proiettili
    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),
    );

    // Controlla se ci sono giocatori morti
    Object.keys(this.sockets).forEach(playerID => {
      const socket = this.sockets[playerID];
      const player = this.players[playerID];
      if (player.hp <= 0) {
        socket.emit(Constants.MSG_TYPES.GAME_OVER);
        this.removePlayer(socket);
      }
    });

    // Invia un aggiornamento di gioco a ciascun giocatore ogni altra volta
    if (this.shouldSendUpdate) {
      const leaderboard = this.getLeaderboard();
      Object.keys(this.sockets).forEach(playerID => {
        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;
    }
  }

  // ...
}

Metodo update() contiene probabilmente la parte più importante della logica lato server. Vediamo in ordine tutto ciò che fa:

  1. Calcola quanto tempo dt è passato dall'ultima volta update().
  2. Aggiorna ogni proiettile e ne distrugge, se necessario. Vedremo l'implementazione di questa funzionalità più tardi. Per ora, è sufficiente sapere che bullet.update() restituisce true, se il proiettile deve essere distrutto (è uscito dai confini dell'arena).
  3. Aggiorna ogni giocatore e crea un proiettile se necessario. Vedremo anche questa implementazione più tardi — player.update() può restituire un oggetto Bullet.
  4. Controlla le collisioni tra proiettili e giocatori utilizzando applyCollisions(), che restituisce un array di proiettili che hanno colpito i giocatori. Per ogni proiettile restituito, aumentiamo i punti del giocatore che lo ha sparato (utilizzando player.onDealtDamage()), e poi rimuoviamo il proiettile dall'array. bullets.
  5. Notifica e distrugge tutti i giocatori uccisi.
  6. Invia aggiornamenti di gioco a tutti i giocatori ogni secondo una volta per chiamata update(). Questo ci aiuta a monitorare la variabile ausiliaria menzionata sopra shouldSendUpdate. Poiché update() viene chiamato 60 volte al secondo, inviamo aggiornamenti di gioco 30 volte al secondo. In questo modo, la frequenza dei clock il server è impostato a 30 tick/s (ne abbiamo parlato nella prima parte riguardo alla frequenza dei tick).

Perché inviare gli aggiornamenti del gioco solo ogni tanto ? Per risparmiare larghezza di banda. 30 aggiornamenti di gioco al secondo sono davvero tanti!

Perché non chiamare update() 30 volte al secondo? Per migliorare la simulazione del gioco. Più frequentemente viene chiamato update(), più precisa sarà la simulazione del gioco. Ma non bisogna esagerare con la quantità di chiamate update(), perché è un compito computazionalmente costoso — 60 al secondo è più che sufficiente.

Il resto della classe Game è composto da metodi ausiliari utilizzati in update():

game.js, parte 3

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() è abbastanza semplice: ordina i giocatori in base al punteggio, prende i cinque migliori e restituisce per ciascuno il nome utente e il punteggio.

createUpdate() è utilizzato in update() per creare aggiornamenti di gioco che vengono inviati ai giocatori. Il suo compito principale è chiamare i metodi serializeForUpdate(), implementati per le classi Player e Bullet. Si noti che trasmette a ciascun giocatore i dati solo sui giocatori più vicini e sugli oggetti – non è necessario trasmettere informazioni sugli oggetti di gioco che si trovano lontano dal giocatore!

3. Oggetti di gioco sul server

Nel nostro gioco, proiettili e giocatori sono in realtà molto simili: sono oggetti di gioco astratti e mobili di forma rotonda. Per sfruttare questa somiglianza tra giocatori e proiettili, iniziamo implementando una classe base 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,
    };
  }
}

Qui non succede nulla di complicato. Questa classe sarà un buon punto di partenza per l'espansione. Vediamo come la classe Bullet utilizza 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;
  }

  // Ritorna vero se il proiettile deve essere distrutto
  update(dt) {
    super.update(dt);
    return this.x  Constants.MAP_SIZE || this.y  Constants.MAP_SIZE;
  }
}

Implementazione Bullet molto breve! Abbiamo aggiunto a Object solo le seguenti estensioni:

  • Utilizzo del pacchetto shortid per la generazione casuale id del proiettile.
  • Aggiunta del campo parentID, per poter tenere traccia del giocatore che ha creato questo proiettile.
  • Aggiunta del valore di ritorno a update(), che è uguale a true, se il proiettile è al di fuori dell'arena (ricorda, ne abbiamo parlato nella sezione precedente?).

Passiamo a 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;
  }

  // Returns a newly created bullet, or null.
  update(dt) {
    super.update(dt);

    // Update score
    this.score += dt * Constants.SCORE_PER_SECOND;

    // Make sure the player stays in bounds
    this.x = Math.max(0, Math.min(Constants.MAP_SIZE, this.x));
    this.y = Math.max(0, Math.min(Constants.MAP_SIZE, this.y));

    // Fire a bullet, if needed
    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,
    };
  }
}

I giocatori sono più complessi dei proiettili, quindi in questa classe devono essere conservati anche alcuni altri campi. Il suo metodo update() esegue un lavoro maggiore, in particolare restituisce solo il proiettile appena creato, se non ce ne sono rimasti. fireCooldown (ricordate, ne abbiamo parlato nella sezione precedente?). Espande anche il metodo serializeForUpdate(), poiché dobbiamo includere nell'aggiornamento del gioco campi aggiuntivi per il giocatore.

La presenza di una classe base Object — un passo importante per evitare la ripetizione del codice. Ad esempio, senza una classe Object ogni oggetto di gioco deve avere la stessa implementazione distanceTo(), e la sincronizzazione del copia e incolla di tutte queste implementazioni in più file sarebbe un incubo. Questo diventa particolarmente importante per grandi progetti, quando il numero delle classi estendenti Object aumenta.

4. Riconoscimento delle collisioni

L'unica cosa che ci resta è riconoscere quando i proiettili colpiscono i giocatori! Ricordate questo frammento di codice dal metodo update() nella classe Game:

game.js

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

class Game {
  // ...

  update() {
    // ...

    // Applica le collisioni, assegna ai giocatori un punteggio per i colpi ricevuti
    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),
    );

    // ...
  }
}

Dobbiamo implementare un metodo applyCollisions(), che restituisca tutti i proiettili che hanno colpito i giocatori. Fortunatamente, non è così difficile farlo, perché

  • tutti gli oggetti che collidono sono cerchi, e questa è una forma semplice per implementare il riconoscimento delle collisioni.
  • Abbiamo già un metodo distanceTo(), che abbiamo implementato nel capitolo precedente nella classe Object.

Ecco come appare la nostra implementazione del riconoscimento delle collisioni:

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

Questo semplice riconoscimento delle collisioni si basa sul fatto che due cerchi collidono se la distanza tra i loro centri è minore della somma dei loro raggi.Ecco un caso in cui la distanza tra i centri di due cerchi è esattamente uguale alla somma dei loro raggi:

Creazione di un gioco web multiplayer nel genere .io
Qui bisogna prestare attenzione ad un paio di aspetti:

  • Il proiettile non deve colpire il giocatore che lo ha creato. Questo può essere ottenuto confrontando bullet.parentID con player.id.
  • Il proiettile deve colpire solo una volta in caso di collisione simultanea con più giocatori. Risolveremo questo problema usando l'operatore break: una volta trovato un giocatore che ha colliso con il proiettile, interrompiamo la ricerca e passiamo al proiettile successivo.

Fine

E questo è tutto! Abbiamo coperto tutto ciò che è necessario sapere per creare un gioco web del genere .io. E adesso? Crea il tuo gioco .io!

Tutto il codice dell'esempio è open source e disponibile su Github.

Fonte: habr.com

Acquista un hosting affidabile per siti web con protezione DDoS, VPS VDS server 🔥 Acquista un hosting affidabile per siti web con protezione DDoS, VPS VDS server | ProHoster