Creazione di un gioco web .io multiplayer

Creazione di un gioco web .io multiplayer
Rilasciato nel 2015 Agar.io divenne il capostipite di un nuovo genere giochi .ioche da allora è cresciuto in popolarità. Ho sperimentato personalmente l'aumento della popolarità dei giochi .io: negli ultimi tre anni ha creato e venduto due giochi di questo genere..

Nel caso in cui non hai mai sentito parlare di questi giochi prima, si tratta di giochi web multiplayer gratuiti facili da giocare (non è richiesto alcun account). Di solito affrontano molti giocatori avversari nella stessa arena. Altri famosi giochi .io: Slither.io и Diep.io.

In questo post esploreremo come creare un gioco .io da zero. Per questo basterà solo la conoscenza di Javascript: bisogna capire cose come la sintassi ES6, parola chiave this и Promesse. Anche se la tua conoscenza di Javascript non è perfetta, puoi comunque comprendere la maggior parte dei post.

Esempio di gioco .io

Per l'assistenza all'apprendimento, faremo riferimento a gioco di esempio .io. Prova a giocarci!

Creazione di un gioco web .io multiplayer
Il gioco è abbastanza semplice: controlli una nave in un'arena dove sono presenti altri giocatori. La tua nave spara automaticamente proiettili e tu provi a colpire altri giocatori evitando i loro proiettili.

1. Breve panoramica/struttura del progetto

raccomandare scaricare il codice sorgente gioco di esempio così puoi seguirmi.

L'esempio utilizza quanto segue:

  • Express è il framework web Node.js più popolare che gestisce il server web del gioco.
  • presa.io - una libreria websocket per lo scambio di dati tra un browser e un server.
  • Webpack - gestore del modulo. 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

pubblico/

Tutto in una cartella public/ verrà inviato staticamente dal server. IN public/assets/ contiene immagini utilizzate dal nostro progetto.

src /

Tutto il codice sorgente è nella cartella src/. Titoli client/ и server/ parlare per se stessi e shared/ contiene un file di costanti che viene importato sia dal client che dal server.

2. Assemblaggi/impostazioni del progetto

Come accennato in precedenza, utilizziamo il gestore moduli per creare il progetto. Webpack. Diamo un'occhiata alla nostra configurazione 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 linee più importanti qui sono:

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

Potresti aver notato nomi di file di pacchetti strani '[name].[contenthash].ext'. Contengono sostituzioni di nomi di file Pacchetto Web: [name] verrà sostituito con il nome del punto di input (nel nostro caso, this game), e [contenthash] verrà sostituito con un hash del contenuto del file. Lo facciamo per ottimizzare il progetto per l'hashing - puoi dire ai browser di memorizzare nella cache i nostri pacchetti JS a tempo indeterminato, perché se un pacchetto cambia, cambia anche il nome del file (i cambiamenti contenthash). Il risultato finale sarà il nome del file di visualizzazione game.dbeee76e91a97d0c7207.js.

file webpack.common.js è il file di configurazione di base che importiamo nelle configurazioni di sviluppo e di progetto finito. Ecco un esempio di 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.jse passa a webpack.prod.jsper ottimizzare le dimensioni del pacchetto durante la distribuzione in produzione.

Impostazione locale

Ti consiglio di installare il progetto sul tuo computer locale in modo da poter seguire i passaggi elencati in questo post. L'installazione è semplice: innanzitutto il sistema deve avere Nodo и NPM. Quindi devi fare

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

e sei pronto per partire! Per avviare il server di sviluppo basta eseguire

$ npm run develop

e vai al browser web localhost: 3000. Il server di sviluppo ricostruirà automaticamente i pacchetti JS e CSS man mano che il codice cambia: basta aggiornare la pagina per vedere tutte le modifiche!

3. Punti di ingresso del cliente

Veniamo al codice del gioco stesso. Per prima cosa abbiamo bisogno di una pagina index.html, quando si visita il sito, il browser lo caricherà per primo. La nostra pagina sarà piuttosto semplice:

index.html

Un esempio di gioco .io  GIOCARE

Questo esempio di codice è stato leggermente semplificato per maggiore chiarezza e farò lo stesso con molti degli altri esempi di post. Il codice completo può sempre essere visualizzato su Github.

Abbiamo:

  • Elemento tela HTML5 (<canvas>) che useremo per renderizzare il gioco.
  • <link> per aggiungere il nostro pacchetto CSS.
  • <script> per aggiungere il nostro pacchetto Javascript.
  • Menu principale con nome utente <input> e il pulsante “PLAY” (<button>).

Dopo aver caricato la home page, il browser inizierà ad eseguire 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 = () => {
    // Play!
    play(usernameInput.value);
    playMenu.classList.add('hidden');
    initState();
    startCapturingInput();
    startRendering();
    setLeaderboardHidden(false);
  };
});

Può sembrare complicato, ma non c'è molto da fare qui:

  1. Importazione di diversi altri file JS.
  2. Importazione CSS (in modo che Webpack sappia includerli nel nostro pacchetto CSS).
  3. Запуск connect() per stabilire una connessione con il server ed eseguire downloadAssets() per scaricare le immagini necessarie per eseguire il rendering del gioco.
  4. Dopo il completamento della fase 3 viene visualizzato il menu principale (playMenu).
  5. Impostazione del gestore per la pressione del pulsante "PLAY". Quando si preme il pulsante, il codice inizializza il gioco e dice al server che siamo pronti per giocare.

La "carne" principale della nostra logica client-server è in quei file che sono stati importati dal file index.js. Ora li considereremo tutti in ordine.

4. Scambio di dati dei clienti

In questo gioco utilizziamo una libreria ben nota per comunicare con il server presa.io. Socket.io ha il supporto nativo WebSockets, che ben si adattano alla comunicazione bidirezionale: possiamo inviare messaggi al server и il server può inviarci messaggi sulla stessa connessione.

Avremo un file src/client/networking.jschi si prenderà cura di tutto comunicazione 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('Connected to server!');
    resolve();
  });
});

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

Anche questo codice è stato leggermente abbreviato per maggiore chiarezza.

Ci sono tre azioni principali in questo file:

  • Stiamo provando a connetterci al server. connectedPromise consentito solo quando abbiamo stabilito una connessione.
  • Se la connessione ha esito positivo, registriamo le funzioni di callback (processGameUpdate() и onGameOver()) per i messaggi che possiamo ricevere dal server.
  • Esportiamo play() и updateDirection()in modo che altri file possano utilizzarli.

5. Rendering del cliente

È ora di visualizzare l'immagine sullo schermo!

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

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

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

La gestione delle risorse non è così difficile da implementare! Il punto principale è memorizzare un oggetto assets, che legherà la chiave del nome file al valore dell'oggetto Image. Quando la risorsa viene caricata, la memorizziamo in un oggetto assets per un accesso rapido in futuro. Quando sarà consentito scaricare ogni singola risorsa (vale a dire, tutti risorse), consentiamo downloadPromise.

Dopo aver scaricato le risorse, puoi iniziare il rendering. Come detto prima, per disegnare su una pagina web, utilizziamo HTML5 Canvas (<canvas>). Il nostro gioco è piuttosto semplice, quindi dobbiamo solo disegnare quanto segue:

  1. sfondo
  2. Nave del giocatore
  3. Altri giocatori nel gioco
  4. cartucce

Ecco i frammenti importanti src/client/render.js, che rendono esattamente i quattro elementi 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;

// Get the canvas graphics context
const canvas = document.getElementById('game-canvas');
const context = canvas.getContext('2d');

// Make the canvas fullscreen
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;

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

  // Draw background
  renderBackground(me.x, me.y);

  // Draw all bullets
  bullets.forEach(renderBullet.bind(null, me));

  // Draw all players
  renderPlayer(me, me);
  others.forEach(renderPlayer.bind(null, me));
}

// ... Helper functions here excluded

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() и stopRendering() controlla l'attivazione del ciclo di rendering a 60 FPS.

Implementazioni concrete di singole funzioni di supporto al rendering (ad es. 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,
  );
}

Tieni presente che stiamo utilizzando il metodo getAsset(), già visto in asset.js!

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

6. Ingresso del cliente

È ora di fare un gioco giocabile! Lo schema di controllo sarà molto semplice: per cambiare la direzione del movimento potrai utilizzare il mouse (su un computer) o toccare lo schermo (su un dispositivo mobile). Per implementare questo ci registreremo Ascoltatori di eventi per gli eventi Mouse e Touch.
Mi occuperò di tutto questo 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() и onTouchInput() sono ascoltatori di eventi che chiamano updateDirection() (da networking.js) quando si verifica un evento di input (ad esempio, quando si sposta il mouse). updateDirection() gestisce la messaggistica con il server, che gestisce l'evento di input e aggiorna di conseguenza lo stato del gioco.

7. Stato del cliente

Questa sezione è la più difficile nella prima parte del post. Non scoraggiarti se non lo capisci la prima volta che lo leggi! Puoi anche saltarlo e tornarci più tardi.

L'ultimo pezzo del puzzle necessario per completare il codice client/server è stato. Ricordi lo snippet di codice dalla sezione Rendering client?

render.js

import { getCurrentState } from './state';

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

  // Do the rendering
  // ...
}

getCurrentState() dovrebbe essere in grado di fornirci lo stato attuale del gioco nel client in qualsiasi momento in base agli aggiornamenti ricevuti dal server. Ecco un esempio di aggiornamento del gioco che il server può 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: timestamp del server che indica quando è stato creato questo aggiornamento.
  • me: informazioni sul giocatore che riceve questo aggiornamento.
  • altri: una serie di informazioni sugli altri giocatori che partecipano allo stesso gioco.
  • proiettili: una serie di informazioni sui proiettili nel gioco.
  • leaderboard: dati della classifica attuale. In questo post non li prenderemo in considerazione.

7.1 Stato del cliente ingenuo

Implementazione ingenua getCurrentState() può restituire direttamente solo i dati dell'aggiornamento del gioco ricevuto più recentemente.

naive-state.js

let lastGameUpdate = null;

// Handle a newly received game update.
export function processGameUpdate(update) {
  lastGameUpdate = update;
}

export function getCurrentState() {
  return lastGameUpdate;
}

Bello e chiaro! Ma se solo fosse così semplice. Uno dei motivi per cui questa implementazione è problematica: limita la frequenza dei fotogrammi di rendering alla frequenza di clock del server.

Frequenza dei fotogrammi: numero di frame (cioè chiamate render()) al secondo o FPS. I giochi di solito cercano di raggiungere almeno 60 FPS.

Tasso di spunta: la frequenza con cui il server invia gli aggiornamenti del gioco ai client. Spesso è inferiore al frame rate. Nel nostro gioco, il server funziona ad una frequenza di 30 cicli al secondo.

Se eseguiamo solo il rendering dell'ultimo aggiornamento del gioco, gli FPS essenzialmente non supereranno mai i 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 ridisegneranno semplicemente la stessa cosa, essenzialmente senza fare nulla. Un altro problema con l'implementazione ingenua è che it soggetto a ritardi. Con una velocità Internet ideale, il client riceverà un aggiornamento del gioco esattamente ogni 33 ms (30 al secondo):

Creazione di un gioco web .io multiplayer
Sfortunatamente, nulla è perfetto. Un quadro più realistico sarebbe:
Creazione di un gioco web .io multiplayer
L'implementazione ingenua è praticamente il caso peggiore in termini di latenza. Se un aggiornamento del gioco viene ricevuto con un ritardo di 50 ms, allora bancarelle dei clienti 50 ms in più perché sta ancora eseguendo il rendering dello stato del gioco dall'aggiornamento precedente. Puoi immaginare quanto sia scomodo per il giocatore: una frenata arbitraria renderà il gioco a scatti e instabile.

7.2 Stato del client migliorato

Apporteremo alcuni miglioramenti all'implementazione naive. Per prima cosa usiamo ritardo nel rendering per 100 ms. Ciò significa che lo stato "attuale" del client sarà sempre in ritardo di 100 ms rispetto allo stato del gioco sul server. Ad esempio, se l'ora sul server è 150, il client restituirà lo stato in cui si trovava il server in quel momento 50:

Creazione di un gioco web .io multiplayer
Questo ci dà un buffer di 100 ms per sopravvivere a tempi di aggiornamento del gioco imprevedibili:

Creazione di un gioco web .io multiplayer
Il profitto per questo sarà permanente ritardo di ingresso per 100 ms. Questo è un piccolo sacrificio per un gameplay fluido: la maggior parte dei giocatori (soprattutto quelli occasionali) non noterà nemmeno questo ritardo. È molto più facile per le persone adattarsi a una latenza costante di 100 ms piuttosto che giocare con una latenza imprevedibile.

Possiamo anche usare un'altra tecnica chiamata "previsioni lato cliente", che fa un buon lavoro nel ridurre la latenza percepita, ma non sarà trattato in questo post.

Un altro miglioramento che stiamo utilizzando è interpolazione lineare. A causa del ritardo nel rendering, di solito siamo almeno un aggiornamento avanti rispetto all'ora corrente nel client. Quando chiamato getCurrentState(), possiamo eseguire interpolazione lineare tra gli aggiornamenti del gioco appena prima e dopo l'ora corrente nel client:

Creazione di un gioco web .io multiplayer
Questo risolve il problema del frame rate: ora possiamo renderizzare fotogrammi unici a qualsiasi frame rate desideriamo!

7.3 Implementazione di uno stato client migliorato

Esempio di implementazione in src/client/state.js utilizza sia il ritardo di rendering che l'interpolazione lineare, ma non per molto. Suddividiamo il codice in due parti. Ecco il primo:

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

Il primo passo è capire cosa currentServerTime(). Come abbiamo visto in precedenza, ogni aggiornamento del gioco include un timestamp del server. Vogliamo utilizzare la latenza di rendering per eseguire il rendering dell'immagine 100 ms dietro al server, ma non sapremo mai l'ora corrente sul server, perché non possiamo sapere quanto tempo ci è voluto prima che gli aggiornamenti ci raggiungessero. Internet è imprevedibile e la sua velocità può variare notevolmente!

Per aggirare questo problema possiamo usare un’approssimazione ragionevole: we fai finta che il primo aggiornamento sia arrivato immediatamente. Se ciò fosse vero, conosceremmo l'ora del server in questo particolare momento! Memorizziamo il timestamp del server in firstServerTimestamp e salva il nostro locale timestamp (client) nello stesso momento in gameStart.

Oh, aspetta un attimo. Non dovrebbe essere ora del server = ora del client? Perché distinguiamo tra "timestamp del server" e "timestamp del client"? Questa è una bella domanda! Si scopre che non sono la stessa cosa. Date.now() restituirà timestamp diversi nel client e nel server e dipende da fattori locali di queste macchine. Non dare mai per scontato che i timestamp saranno gli stessi su tutte le macchine.

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

Ora diamo un'occhiata a come gestiamo gli aggiornamenti del gioco. Quando viene ricevuto dal server di aggiornamento, viene chiamato processGameUpdate()e salviamo il nuovo aggiornamento in un array gameUpdates. Quindi, per verificare l'utilizzo della memoria, rimuoviamo prima tutti i vecchi aggiornamenti aggiornamento di baseperché non ne abbiamo più bisogno.

Cos'è un "aggiornamento di base"? Questo il primo aggiornamento lo troviamo spostandoci indietro rispetto all'ora corrente del server. Ricordi questo diagramma?

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

A cosa serve l'aggiornamento di base? Perché possiamo eliminare gli aggiornamenti alla baseline? Per capirlo, andiamo infine considerare 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),
    };
  }
}

Trattiamo tre casi:

  1. base < 0 significa che non ci sono aggiornamenti fino al momento del rendering corrente (vedi sopra implementazione getBaseUpdate()). Ciò può accadere proprio all'inizio del gioco a causa del ritardo nel rendering. In questo caso utilizziamo l'ultimo aggiornamento ricevuto.
  2. base è l'ultimo aggiornamento che abbiamo. Ciò potrebbe essere dovuto a un ritardo della rete o a una connessione Internet scadente. In questo caso, stiamo utilizzando anche l'ultimo aggiornamento a nostra disposizione.
  3. Abbiamo un aggiornamento sia prima che dopo l'ora di rendering corrente, quindi possiamo farlo interpolare!

Tutto quello che è rimasto dentro state.js è un'implementazione dell'interpolazione lineare che è matematica semplice (ma noiosa). Se vuoi esplorarlo tu stesso, allora apri state.js su Github.

Parte 2. Server backend

In questa parte daremo uno sguardo al backend Node.js che controlla il nostro file esempio di un gioco .io.

1. Punto di ingresso del server

Per gestire il server web, utilizzeremo un popolare framework web per Node.js chiamato Express. Verrà configurato dal file del punto di ingresso del nostro 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');

// Setup an Express server
const app = express();
app.use(express.static('public'));

if (process.env.NODE_ENV === 'development') {
  // Setup Webpack for development
  const compiler = webpack(webpackConfig);
  app.use(webpackDevMiddleware(compiler));
} else {
  // Static serve the dist/ folder in production
  app.use(express.static('dist'));
}

// Listen on port
const port = process.env.PORT || 3000;
const server = app.listen(port);
console.log(`Server listening on port ${port}`);

Ricordi che nella prima parte abbiamo parlato di Webpack? Qui è dove utilizzeremo le nostre configurazioni Webpack. Li useremo in due modi:

  • Da usare webpack-dev-middleware per ricostruire automaticamente i nostri pacchetti di sviluppo, o
  • cartella di trasferimento statico dist/, in cui Webpack scriverà i nostri file dopo la build di produzione.

Un altro compito importante server.js è configurare il server presa.ioche si connette semplicemente al server Express:

server.js parte 2

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

// Setup Express
// ...
const server = app.listen(port);
console.log(`Server listening on port ${port}`);

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

// Listen for socket.io connections
io.on('connection', socket => {
  console.log('Player connected!', 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 una connessione socket.io al server, configuriamo i gestori di eventi per il nuovo socket. I gestori eventi gestiscono i messaggi ricevuti dai client delegando a un oggetto singleton game:

server.js parte 3

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

// ...

// Setup the Game
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 .io, quindi abbiamo bisogno solo di una copia Game ("Gioco"): tutti i giocatori giocano nella stessa arena! Nella prossima sezione vedremo come funziona questa classe. Game.

2. Server di gioco

classe Game contiene la logica più importante sul lato server. Ha due compiti principali: gestione dei giocatori и simulazione di gioco.

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

    // Generate a position to start this player at.
    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 in base al campo id il loro socket socket.io (se ti confondi, torna a server.js). Socket.io stesso assegna a ciascun socket un nome univoco idquindi non dobbiamo preoccuparci di questo. Lo chiamerò ID del giocatore.

Con questo in mente, esploriamo le variabili di istanza in una classe Game:

  • sockets è un oggetto che associa l'ID del lettore al socket associato al lettore. Ci consente di accedere ai socket tramite i loro ID giocatore in un tempo costante.
  • players è un oggetto che lega l'ID giocatore all'oggetto code>Player

bullets è un array di oggetti Bullet, che non ha un ordine definito.
lastUpdateTime è il timestamp dell'ultimo aggiornamento del gioco. Vedremo a breve come viene utilizzato.
shouldSendUpdate è una variabile ausiliaria. Tra poco ne vedremo anche l'utilizzo.
Методы addPlayer(), removePlayer() и handleInput() non c'è bisogno di spiegare, sono usati in server.js. Se hai bisogno di rinfrescarti la memoria, torna un po’ più in alto.

Ultima linea constructor() si avvia ciclo di aggiornamento giochi (con una frequenza di 60 aggiornamenti/s):

game.js parte 2

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

class Game {
  // ...

  update() {
    // Calculate time elapsed
    const now = Date.now();
    const dt = (now - this.lastUpdateTime) / 1000;
    this.lastUpdateTime = now;

    // Update each bullet
    const bulletsToRemove = [];
    this.bullets.forEach(bullet => {
      if (bullet.update(dt)) {
        // Destroy this bullet
        bulletsToRemove.push(bullet);
      }
    });
    this.bullets = this.bullets.filter(
      bullet => !bulletsToRemove.includes(bullet),
    );

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

    // Apply collisions, give players score for hitting bullets
    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),
    );

    // Check if any players are dead
    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);
      }
    });

    // Send a game update to each player every other time
    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 forse la parte più importante della logica lato server. Ecco cosa fa, in ordine:

  1. Calcola quanto tempo dt passato dall'ultimo update().
  2. Aggiorna ogni proiettile e li distrugge se necessario. Vedremo più avanti l'implementazione di questa funzionalità. Per ora ci basta saperlo bullet.update() ritorna truese il proiettile dovesse essere distrutto (uscì dall'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 proiettili e giocatori che utilizzano applyCollisions(), che restituisce una serie di proiettili che colpiscono i giocatori. Per ogni proiettile restituito aumentiamo i punti del giocatore che lo ha sparato (usando player.onDealtDamage()) e quindi rimuovere il proiettile dalla matrice bullets.
  5. Avvisa e distrugge tutti i giocatori uccisi.
  6. Invia un aggiornamento del gioco a tutti i giocatori ogni secondo volte in cui viene chiamato update(). Questo ci aiuta a tenere traccia della variabile ausiliaria menzionata sopra. shouldSendUpdate... Come update() chiamato 60 volte/s, inviamo aggiornamenti di gioco 30 volte/s. Così, frequenza dell'orologio il clock del server è di 30 clock/s (abbiamo parlato delle frequenze di clock nella prima parte).

Perché inviare solo aggiornamenti di gioco attraverso il tempo ? Per salvare il canale. 30 aggiornamenti di gioco al secondo sono tanti!

Perché non chiamare e basta update() 30 volte al secondo? Per migliorare la simulazione del gioco. Il più spesso chiamato update(), più accurata sarà la simulazione del gioco. Ma non lasciarti trasportare troppo dal numero di sfide. update(), perché questo è un compito computazionalmente costoso: 60 al secondo sono abbastanza.

Il resto della classe Game è costituito da metodi di supporto 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) <= Constants.MAP_SIZE / 2,
    );
    const nearbyBullets = this.bullets.filter(
      b => b.distanceTo(player) <= Constants.MAP_SIZE / 2,
    );

    return {
      t: Date.now(),
      me: player.serializeForUpdate(),
      others: nearbyPlayers.map(p => p.serializeForUpdate()),
      bullets: nearbyBullets.map(b => b.serializeForUpdate()),
      leaderboard,
    };
  }
}

getLeaderboard() piuttosto semplice: ordina i giocatori in base al punteggio, prende i primi cinque e restituisce il nome utente e il punteggio per ciascuno.

createUpdate() usato in update() per creare aggiornamenti di gioco distribuiti ai giocatori. Il suo compito principale è chiamare metodi serializeForUpdate()implementato per le classi Player и Bullet. Tieni presente che trasmette solo i dati a ciascun giocatore più vicino giocatori e proiettili: non è necessario trasmettere informazioni sugli oggetti di gioco lontani dal giocatore!

3. Oggetti di gioco sul server

Nel nostro gioco, i proiettili e i giocatori sono in realtà molto simili: sono oggetti di gioco astratti, rotondi e mobili. Per sfruttare questa somiglianza tra giocatori e proiettili, iniziamo implementando la classe base Object:

oggetto.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 lezione sarà un buon punto di partenza per l'espansione. Vediamo come si svolge la lezione Bullet usi 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;
  }

  // Returns true if the bullet should be destroyed
  update(dt) {
    super.update(dt);
    return this.x < 0 || this.x > Constants.MAP_SIZE || this.y < 0 || this.y > Constants.MAP_SIZE;
  }
}

implementazione Bullet molto corto! Abbiamo aggiunto a Object solo le seguenti estensioni:

  • Utilizzando un pacchetto corto per la generazione casuale id proiettile.
  • Aggiunta di un campo parentIDin modo da poter rintracciare il giocatore che ha creato questo proiettile.
  • Aggiunta di un valore restituito a update()che è uguale truese il proiettile è fuori dall'arena (ricordate che ne abbiamo parlato nell'ultima sezione?).

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 dovrebbero essere memorizzati alcuni campi in più. Il suo metodo update() fa molto lavoro, in particolare, restituisce il proiettile appena creato se non ne è rimasto nessuno fireCooldown (ricordi che ne abbiamo parlato nella sezione precedente?). Inoltre estende il metodo serializeForUpdate(), perché dobbiamo includere campi aggiuntivi per il giocatore nell'aggiornamento del gioco.

Disponibilità di una classe base Object - un passo importante per evitare di ripetere il codice. Ad esempio, senza lezione Object ogni oggetto di gioco deve avere la stessa implementazione distanceTo()e copiare e incollare tutte queste implementazioni su più file sarebbe un incubo. Ciò diventa particolarmente importante per i grandi progetti.quando il numero di espansione Object le classi crescono

4. Rilevamento delle collisioni

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

gioco.js

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

class Game {
  // ...

  update() {
    // ...

    // Apply collisions, give players score for hitting bullets
    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ì difficile da fare perché

  • Tutti gli oggetti in collisione sono cerchi e questa è la forma più semplice per implementare il rilevamento delle collisioni.
  • Abbiamo già un metodo distanceTo(), che abbiamo implementato nella sezione precedente della classe Object.

Ecco come appare la nostra implementazione del rilevamento delle collisioni:

collisioni.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 rilevamento delle collisioni si basa sul fatto che due cerchi si scontrano 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 .io multiplayer
Ci sono un paio di altri aspetti da considerare qui:

  • Il proiettile non deve colpire il giocatore che lo ha creato. Ciò può essere ottenuto confrontando bullet.parentID с player.id.
  • Il proiettile deve colpire una sola volta nel caso limite di più giocatori che si scontrano contemporaneamente. Risolveremo questo problema utilizzando l'operatore break: Una volta trovato un giocatore che si scontra con un proiettile, interrompiamo la ricerca e passiamo al proiettile successivo.

fine

È tutto! Abbiamo coperto tutto ciò che devi sapere per creare un gioco web .io. Qual è il prossimo? Costruisci il tuo gioco .io!

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

Fonte: habr.com

Aggiungi un commento