Creazione di un gioco web multiplayer in stile .io

Creazione di un gioco web multiplayer in stile .io
Uscita nel 2015 Agar.io è diventata l'antesignana di un nuovo genere di giochi .io, la cui popolarità è notevolmente aumentata da allora. Ho sperimentato in prima persona la crescita della popolarità dei giochi .io: 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, a cui è facile partecipare (non è necessaria un'account). Di solito, raggruppano un gran numero di giocatori in un'unica arena. Altri famosi giochi del genere .io: Slither.io e Diep.io.

In questo post parleremo di come creare un gioco .io da zero. Se ne ha bisogno, basta conoscere JavaScript: è importante comprendere cose come la sintassi ES6, la parola chiave this e Promises. Anche se non si conosce JavaScript a menadito, si potrà comunque capire la maggior parte del post.

Esempio di gioco .io

Per aiutare nell'apprendimento faremo riferimento a un esempio di gioco .io. Prova a giocarci!

Creazione di un gioco web multiplayer in stile .io
Il gioco è abbastanza semplice: controlli una nave nell'arena, dove ci sono altri giocatori. La tua nave spara automaticamente proiettili e cerchi di colpire gli altri giocatori, mentre eviti i loro proiettili.

1. Panoramica / Struttura del progetto

Ti consiglio di scaricare il codice sorgente un esempio di gioco, in modo da poter seguire il mio esempio.

Nell'esempio vengono utilizzati i seguenti:

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

Ecco come appare la struttura delle cartelle del progetto:

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

public/

Tutto nella cartella public/ sarà trasmesso staticamente dal server. In public/assets/ si trovano le immagini utilizzate dal nostro progetto.

src/

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

2. Assemblaggi / Opzioni del progetto

Come detto sopra, per costruire il progetto utilizziamo un 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 seguenti righe sono le più importanti:

  • src/client/index.js — questo è il punto di ingresso del client Javascript (JS). Webpack inizierà da qui e cercherà in modo ricorsivo altri file importati.
  • Il JS di output della nostra build Webpack sarà situato nella directory dist/. Lo chiamerò il nostro pacchetto JS.
  • Lo usiamo Babel, e in particolare la configurazione @babel/preset-env per la transpiling (trasformazione) del nostro codice JS per i browser più vecchi.
  • Utilizziamo un plugin per estrarre tutto il CSS a cui fanno riferimento i file JS e per unirli in un unico posto. Lo chiamerò il nostro pacchetto CSS.

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

all'interno di ogni container per impostazione predefinita apparirà così: webpack.common.js — è il file di configurazione di base che importiamo nelle configurazioni di sviluppo e del progetto pronto. 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 cambia a webpack.prod.js, per ottimizzare le dimensioni dei pacchetti durante il deployment in produzione.

Impostazione locale

Consiglio di impostare il progetto sulla macchina locale, in modo da poter seguire i passaggi elencati in questo post. La configurazione è semplice: prima di tutto, devono essere installati nel sistema Node e NPM. Poi bisogna eseguire

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

e siete pronti a lavorare! Per avviare il server di sviluppo è sufficiente eseguire

$ npm run develop

e accedere al browser web su localhost:3000. Il server di sviluppo ricomporrà automaticamente i pacchetti JS e CSS mentre il codice viene modificato: basta aggiornare la pagina per vedere tutte le modifiche!

3. Punti di ingresso del client

Iniziamo a scrivere il codice del gioco. Per cominciare, avremo bisogno di una pagina index.html, che verrà caricata per prima quando il sito viene visitato. La nostra pagina sarà abbastanza semplice:

index.html

<!DOCTYPE html>
<html>
<head>
  <title>Un esempio di gioco .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 è stato leggermente semplificato per chiarezza; farò lo stesso con molti altri esempi nel post. Il codice completo è sempre consultabile su Github.

Abbiamo:

  • Elemento HTML5 Canvas (<canvas>), che useremo 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 pagina principale nel browser, verrà eseguito il codice Javascript, a partire dal file JS del punto 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 = () => {
    // Gioca!
    play(usernameInput.value);
    playMenu.classList.add('hidden');
    initState();
    startCapturingInput();
    startRendering();
    setLeaderboardHidden(false);
  };
});

Questo potrebbe sembrare complicato, ma in realtà non ci sono molte azioni coinvolte:

  1. Importazione di diversi altri file JS.
  2. Importazione di 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 per il rendering del gioco.
  4. Al termine della fase 3 viene visualizzato il menu principale (playMenu).
  5. Impostazione del gestore di eventi per il pulsante «PLAY». Quando si preme il pulsante, il codice inizializza il gioco e avvisa il server che siamo pronti a giocare.

Il "nocciolo" della nostra logica client-server si trova nei file importati dal file index.js. Ora li esamineremo uno per uno.

4. Scambio di dati del client

In questo gioco, per comunicare con il server utilizziamo una libreria ben nota socket.io. In Socket.io c'è un supporto integrato WebSockets, che si adatta bene alla comunicazione bidirezionale: possiamo inviare messaggi al server e il server può inviarci messaggi sulla 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 è anche leggermente abbreviato per semplicità.

In questo file si svolgono tre attività principali:

  • Proviamo a connetterci al server. connectedPromise si risolve solo quando abbiamo stabilito la connessione.
  • Se la connessione viene stabilita con successo, 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 un'immagine sullo schermo!

…ma prima di poter fare ciò, 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! Il concetto principale è memorizzare un oggetto assets, che collegherà la chiave del nome del file al valore dell'oggetto Immagine. Quando la risorsa verrà caricata, la salveremo nell'oggetto assets per un accesso rapido in futuro. Quando il download di ciascuna risorsa viene completato (ovvero le tutti risorse verranno caricate), risolviamo downloadPromise.

Dopo aver scaricato le risorse, possiamo procedere con il rendering. Come già detto, per disegnare sulla pagina web utilizziamo HTML5 Canvas (<canvas>). Il nostro gioco è piuttosto semplice, quindi ci basta disegnare solo quanto segue:

  1. Sfondo
  2. La 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 menzionati:

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 è stato anche abbreviato per chiarezza.

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

Le implementazioni specifiche delle singole funzioni di supporto per il 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 visto in precedenza in asset.js!

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

6. Input del client

È tempo di rendere il gioco giocabile! Il sistema di controllo sarà molto semplice: per cambiare la direzione del movimento, puoi utilizzare il mouse (sul computer) o toccare lo schermo (su dispositivo mobile). Per realizzarlo, registreremo Event Listeners per eventi Mouse e Touch.
Tutto ciò 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 i più Event Listeners che attivano updateDirection() (da networking.js) quando si verifica un evento di input (ad esempio, quando si muove il mouse). updateDirection() gestisce lo scambio di messaggi con il server, che elabora l'evento di input e aggiorna lo stato del gioco di conseguenza.

7. Stato del client

Questa sezione è la più complessa della prima parte del post. Non ti preoccupare 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 nella 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 sul client in qualsiasi momento sulla base degli aggiornamenti ricevuti dal server. Ecco un esempio di aggiornamento del gioco che il server potrebbe inviare:

{
  "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: un timestamp del server che indica il momento della creazione di questo aggiornamento.
  • me: informazioni sul giocatore che riceve questo aggiornamento.
  • others: un array di informazioni sugli altri giocatori coinvolti nello stesso gioco.
  • bullets: un array di informazioni sui proiettili nel gioco.
  • leaderboard: dati attuali della classifica. In questo post non li prenderemo in considerazione.

7.1 Stato naive del client

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

naive-state.js

let lastGameUpdate = null;

// Gestisci un nuovo aggiornamento del gioco ricevuto.
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 è che limita il frame rate del rendering alla frequenza di aggiornamento del server.

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

Tick Rate: la frequenza con cui il server invia aggiornamenti del gioco ai client. Spesso è inferiore al frame rate. Nel nostro gioco, il server opera a una frequenza di 30 tick al secondo.

Se ci limiteremo a rendere l'ultimo aggiornamento del gioco, gli FPS non potranno mai superare 30, perché non riceviamo mai più di 30 aggiornamenti al secondo dal server. Anche se chiamiamo render() 60 volte al secondo, metà di queste chiamate semplicemente ridisegnerà la stessa cosa, sostanzialmente senza fare nulla. Un altro problema di un'implementazione naif è che è soggetta a latenze. Con una velocità di Internet ideale, il client riceverà un aggiornamento del gioco esattamente ogni 33 ms (30 al secondo):

Creazione di un gioco web multiplayer in stile .io
Purtroppo, nulla è perfetto. Un'immagine più realistica sarebbe questa:
Creazione di un gioco web multiplayer in stile .io
L'implementazione naif è praticamente il peggior caso in termini di latenze. Se un aggiornamento del gioco viene ricevuto con un ritardo di 50 ms, allora il client rallenta di ulteriori 50 ms, perché continua a rendere lo stato del gioco dall'aggiornamento precedente. Puoi immaginare quanto sia scomodo per il giocatore: a causa di rallentamenti arbitrari il gioco apparirà scattoso e instabile.

7.2 Stato migliorato del client

Effettueremo alcuni miglioramenti all'implementazione naif. Prima di tutto, utilizzeremo un ritardo di rendering di 100 ms. Ciò significa che lo stato 'corrente' del client sarà sempre in ritardo rispetto allo stato del gioco sul server di 100 ms. Ad esempio, se sul server il tempo è 150, lo stato renderizzato dal client sarà quello in cui si trovava il server al momento di 50:

Creazione di un gioco web multiplayer in stile .io
Questo ci fornisce un buffer di 100 ms, che consente di affrontare il tempo imprevedibile di ricezione degli aggiornamenti del gioco:

Creazione di un gioco web multiplayer in stile .io
Il prezzo da pagare per questo sarà un costante ritardo di input di 100 ms. Questo è un sacrificio insignificante per un'esperienza di gioco fluida: la maggior parte dei giocatori (soprattutto i casual) nemmeno noterà questo ritardo. È molto più facile per le persone adattarsi a un ritardo costante di 100 ms piuttosto che giocare con un ritardo imprevedibile.

Possiamo utilizzare anche un'altra tecnica chiamata ‘previsione lato client’, che affronta bene la riduzione delle latenze percepite, ma non verrà considerata in questo post.

Un ulteriore miglioramento che utilizziamo è l'interpolazione lineare. A causa del ritardo di rendering, di solito superiamo lo stato attuale nel client di almeno un aggiornamento. Quando viene chiamato getCurrentState(), possiamo eseguire un'interpolazione lineare tra gli aggiornamenti del gioco immediatamente prima e dopo l'orario corrente nel client:

Creazione di un gioco web multiplayer in stile .io
Questo risolve il problema con la frequenza dei fotogrammi: ora possiamo renderizzare fotogrammi unici con qualsiasi frequenza ci serva!

7.3 Implementazione di uno stato client migliorato

Esempio di implementazione in src/client/state.js usa sia il ritardo di rendering che l'interpolazione lineare, ma solo per un breve periodo. Dobbiamo dividere 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);

  // Mantieni solo un aggiornamento di gioco prima dell'orario corrente del server
  const base = getBaseUpdate();
  if (base > 0) {
    gameUpdates.splice(0, base);
  }
}

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

// Restituisce l'indice dell'aggiornamento base, il primo aggiornamento di gioco prima
// dell'orario corrente del server, o -1 se 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;
}

Per prima cosa dobbiamo capire cosa fa currentServerTime(). Come abbiamo visto in precedenza, in ogni aggiornamento del gioco è inclusa la marca temporale del server. Vogliamo utilizzare il ritardo di rendering per renderizzare l'immagine con un ritardo di 100 ms dal server, ma non sapremo mai qual è l'ora attuale sul server, perché non possiamo sapere quanto tempo ha impiegato ciascun aggiornamento per raggiungerci. Internet è imprevedibile e la sua velocità può variare notevolmente!

Per aggirare questo problema, possiamo utilizzare un'adeguata approssimazione: noi fingiamo che il primo aggiornamento sia arrivato istantaneamente. Se fosse vero, sapremmo l'ora del server in quel momento specifico! Salviamo la marca temporale del server in firstServerTimestamp e salviamo la nostra marca temporale locale (client) nel medesimo momento in.

gameStart Oh, aspetta un attimo. Non dovrebbe essere l'ora del server = l'ora nel client? Perché distinguiamo tra "marca temporale del server" e "marca temporale del client"? È una bellissima domanda! Si scopre che non sono la stessa cosa. Date.now() restituirà marche temporali diverse nel client e nel server e ciò dipende da fattori locali per quelle macchine.

Non dare mai per scontato che le marche temporali siano le stesse su tutte le macchine. currentServerTime()Ora ci è chiaro cosa fa : restituisce la marca temporale del server per l'ora corrente di rendering. In altre parole, questo è l'orario attuale del server (firstServerTimestamp < (Date.now() - gameStart)) meno il ritardo di rendering (RENDER_DELAY).

Ora vediamo come gestiamo gli aggiornamenti di gioco. Quando riceviamo un aggiornamento dal server, viene chiamato processGameUpdate(), e salviamo il nuovo aggiornamento nell'array gameUpdates. Poi, per monitorare l'utilizzo della memoria, rimuoviamo tutti gli aggiornamenti obsoleti fino a aggiornamento di base, perché non ci servono più.

Che cos'è un «aggiornamento di base»? È il primo aggiornamento che troviamo muovendoci all'indietro dall'orario attuale del server. Ricordi questo schema?

Creazione di un gioco web multiplayer in stile .io
L'aggiornamento di gioco direttamente a sinistra del «Client Render Time» è l'aggiornamento di base.

A cosa serve l'aggiornamento di base? Perché possiamo scartare aggiornamenti fino al base? Per chiarire 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'orario attuale di rendering (vedi l'implementazione sopra di getBaseUpdate()). Questo può accadere all'inizio del gioco a causa del ritardo di rendering. In tal caso, usiamo l'ultimo aggiornamento ricevuto.
  2. base — è l'ultimo aggiornamento che abbiamo. Questo può verificarsi a causa di ritardi di rete o connessione Internet scadente. Anche in questo caso utilizziamo l'ultimo aggiornamento che abbiamo.
  3. Abbiamo aggiornamenti sia prima che dopo l'orario attuale di rendering, quindi possiamo interpolare!

Tutto ciò che resta in state.js è l'implementazione dell'interpolazione lineare, che è una matematica semplice (ma noiosa). Se vuoi esplorarlo da solo, apri state.js in Github.

Parte 2. Server backend

In questa parte esamineremo il backend Node.js che gestisce il nostro 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 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');

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

if (process.env.NODE_ENV === 'development') {
  // Imposta Webpack per lo sviluppo
  const compiler = webpack(webpackConfig);
  app.use(webpackDevMiddleware(compiler));
} else {
  // Serve 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 che utilizzeremo le nostre configurazioni Webpack. Le applicheremo in due modi:

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

Un'altra importante funzione server.js consiste nell'impostare 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');

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

// Imposta 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 di eventi per il nuovo socket. I gestori di eventi elaborano i messaggi ricevuti dai clienti delegando all'oggetto singletone game:

server.js, parte 3

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

// ...

// Imposta 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 del genere .io, quindi abbiamo bisogno di un solo esemplare Game ('Game') – tutti i giocatori giocano su un'unica arena! Nella sezione successiva vedremo come funziona questa classe Game.

2. Game del server

Classe Game contiene la logica più importante lato server. Ha due principali compiti: gestire i giocatori e simulare il 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 far partire 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 ti senti confuso, torna ancora a server.js). Socket.io assegna automaticamente a ogni socket un idunico ID, quindi non dobbiamo preoccuparcene. Lo chiamerò ID giocatore.

Ricordato questo, esaminiamo le variabili d'istanza nella classe Game:

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

bullets è un array di oggetti Bullet, senza un ordine specifico.
lastUpdateTime è un timestamp dell'ultimo aggiornamento del gioco. Tra poco vedremo come viene utilizzato.
shouldSendUpdate è una variabile ausiliaria. Anche il suo utilizzo lo vedremo presto.
Metodi addPlayer(), removePlayer() e handleInput() non necessitano di spiegazioni, vengono utilizzati in server.js. Se hai bisogno di un ripasso, torna un po' indietro.

L'ultima linea constructor() avvia il ciclo di aggiornamento del gioco (con 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 ai giocatori un punteggio 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  {
        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;
    }
  }

  \/\/ ...
}

Sanitizer.replaceElementWithChildren() update() contiene, probabilmente, la parte più importante della logica sul lato server. Elencheremo in ordine tutto ciò che fa:

  1. Calcola quanto tempo dt è passato dall'ultimo update().
  2. Aggiorna ogni proiettile e lo distrugge se necessario. Vedremo l'implementazione di questa funzionalità più avanti. Al momento ci basta sapere che bullet.update() false, se l'elemento da sostituire è true, se il proiettile deve essere distrutto (è uscito dai confini dell'arena).
  3. Aggiorna ogni giocatore e genera un proiettile se necessario. Vedremo anche questa implementazione più avanti — player.update() può restituire un oggetto Bullet.
  4. Controlla le collisioni tra i proiettili e i giocatori utilizzando applyCollisions(), che restituisce un array di proiettili che hanno colpito i giocatori. Per ogni proiettile restituito aumentiamo il punteggio del giocatore che lo ha lanciato (utilizzando player.onDealtDamage()), e poi rimuoviamo il proiettile dall'array bullets.
  5. Notifica e distrugge tutti i giocatori uccisi.
  6. Invia a tutti i giocatori un aggiornamento del gioco ogni secondo volta in cui viene chiamato update(). Questo ci aiuta a seguire la variabile ausiliaria menzionata sopra shouldSendUpdate. Poiché update() viene chiamato 60 volte/s, inviamo aggiornamenti di gioco 30 volte/s. In questo modo, frequenza di clock del server è di 30 cicli/s (abbiamo parlato della frequenza di clock nella prima parte).

Perché inviare aggiornamenti di gioco solo ogni due volte ? Per risparmiare banda. 30 aggiornamenti di gioco al secondo sono davvero tanti!

Perché non chiamarlo 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 il numero di chiamate update(), perché questo è un compito che richiede molto calcolo: 60 al secondo sono più che sufficienti.

La parte rimanente della classe Game è composta 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 ai punti, prende i migliori cinque e restituisce per ciascuno il nome utente e il punteggio.

createUpdate() viene utilizzato in update() per creare aggiornamenti di gioco che vengono inviati ai giocatori. Il suo compito principale consiste nel chiamare i metodi serializeForUpdate(), implementati per le classi Player e Bullet. Nota che invia a ciascun giocatore solo informazioni sui giocatori vicini e proiettili: non c'è bisogno di inviare 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, rotondi e mobili. Per sfruttare questa somiglianza tra giocatori e proiettili, iniziamo con l'implementazione della classe di 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,
    };
  }
}

Non c'è niente di complicato qui. 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 true 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 corta! Abbiamo aggiunto a Object solo le seguenti estensioni:

  • Utilizzo del pacchetto shortid per la generazione casuale id del proiettile.
  • Aggiunta di un campo parentID, per tenere traccia del giocatore che ha creato questo proiettile.
  • Aggiunta di un valore di ritorno a update(), che è uguale a true, se il proiettile si trova oltre i confini 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;
  }

  \/\/ Ritorna un nuovo proiettile creato, o null.
  update(dt) {
    super.update(dt);

    \/\/ Aggiorna il punteggio
    this.score += dt * Constants.SCORE_PER_SECOND;

    \/\/ Assicurati che il giocatore rimanga entro i limiti
    this.x = Math.max(0, Math.min(Constants.MAP_SIZE, this.x));
    this.y = Math.max(0, Math.min(Constants.MAP_SIZE, this.y));

    \/\/ Spara un proiettile, se necessario
    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 altri campi. Il suo metodo update() esegue un lavoro maggiore, restituendo un proiettile appena creato, se non è rimasto fireCooldown (ricorda, ne abbiamo parlato nella sezione precedente?). Estende anche il metodo serializeForUpdate(), poiché dobbiamo includere nel gioco dati aggiuntivi per il giocatore.

Avere una classe base Object è un passo importante per evitare la ripetizione del codice. Ad esempio, senza la classe Object ogni oggetto di gioco dovrebbe avere la stessa implementazione di distanceTo(), e la sincronizzazione della copia di tutte queste implementazioni in vari file sarebbe un incubo. Ciò diventa particolarmente importante per i progetti di grandi dimensioni, quando il numero di classi che estendono aumenta. Object 4. Riconoscimento delle collisioni

4. Riconoscimento delle collisioni

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

game.js

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

class Game {
  \/\/ ...

  update() {
    \/\/ ...

    \/\/ Applica le collisioni, assegna punteggio ai giocatori per i proiettili colpiti
    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 il metodo applyCollisions(), che restituisce tutti i proiettili che colpiscono i giocatori. Fortunatamente, non è così complicato farlo, perché

  • Tutti gli oggetti in collisione sono cerchi, e questa è la figura più semplice per implementare il riconoscimento delle collisioni.
  • Abbiamo già un metodo distanceTo(), che abbiamo implementato nella classe nel paragrafo precedente. 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 in stile .io
Qui dobbiamo prestare attenzione anche a un paio di aspetti:

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

Fine

Ecco fatto! Abbiamo esaminato tutto ciò che è necessario sapere per creare un gioco web del genere .io. E ora? Crea il tuo gioco .io!

Tutto il codice di esempio è open source e pubblicato su Github.

Fonte: habr.com

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