Crià un Multiplayer .io Web Game

Crià un Multiplayer .io Web Game
Rilasciatu in u 2015 Agar.io divintò u progenitore di un novu genre ghjochi .iochì hà crisciutu in pupularità da tandu. Aghju avutu personalmente l'aumentu di a popularità di i ghjoculi .io: in l'ultimi trè anni, aghju criatu è vindutu dui ghjochi di stu generu..

In casu chì ùn avete mai intesu parlà di sti ghjochi prima, questi sò ghjochi web multiplayer gratuiti chì sò faciuli di ghjucà (senza un contu necessariu). Di solitu affrontanu parechji ghjucadori opposti in a listessa arena. Altri famosi ghjochi .io: Slither.io и Diep.io.

In questu post, esploreremu cumu creà un ghjocu .io da zero. Per questu, solu a cunniscenza di Javascript serà abbastanza: avete bisognu di capiscenu cose cum'è sintassi ES6, keyword this и s'arrinnìu facirmenti. Ancu s'è a vostra cunniscenza di Javascript ùn hè micca perfetta, pudete ancu capisce a maiò parte di u post.

Esempiu di ghjocu .io

Per l'assistenza à l'apprendimentu, avemu da riferite Esempiu di ghjocu .io. Pruvate à ghjucà!

Crià un Multiplayer .io Web Game
U ghjocu hè abbastanza simplice: cuntrole una nave in una arena induve ci sò altri ghjucatori. A vostra nave spara automaticamente proiettili è pruvate à chjappà altri ghjucatori mentre evitendu i so prughjetti.

1. Breve panoramica / struttura di u prugettu

Vi ricu scaricate u codice fonte ghjocu esempiu cusì vi pò seguità mi.

L'esempiu usa i seguenti:

  • EXPRESS hè u framework web Node.js più populari chì gestisce u servitore web di u ghjocu.
  • socket.io - una biblioteca websocket per u scambiu di dati trà un navigatore è un servitore.
  • Webpack - gestore di moduli. Pudete leghje perchè aduprà Webpack. ccà.

Eccu ciò chì pare a struttura di u cartulare di u prughjettu:

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

publicu/

Tuttu in un cartulare public/ serà sottumessu staticamente da u servitore. IN public/assets/ cuntene l'imaghjini utilizati da u nostru prughjettu.

src /

Tuttu u codice fonte hè in u cartulare src/. Tituli client/ и server/ parlà per elli stessi è shared/ cuntene un schedariu custanti chì hè impurtatu da u cliente è u servitore.

2. Assemblea / paràmetri di prughjettu

Cumu l'esitatu sopra, usemu u gestore di moduli per custruisce u prughjettu. Webpack. Fighjemu a nostra cunfigurazione 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',
    }),
  ],
};

I linii più impurtanti quì sò:

  • src/client/index.js hè u puntu di entrata di u cliente Javascript (JS). Webpack partirà da quì è cercherà recursivamente per altri schedari impurtati.
  • L'output JS di u nostru Webpack build serà situatu in u cartulare dist/. Chjameraghju stu schedariu u nostru pacchettu js.
  • Usemu Babel, è in particulare a cunfigurazione @babel/preset-env per traspilà u nostru codice JS per i navigatori più vechji.
  • Utilizemu un plugin per estrattà tutti i CSS riferiti da i schedari JS è combina in un locu. U chjamaraghju u nostru pacchettu css.

Puderete avè nutatu nomi di file di pacchetti strani '[name].[contenthash].ext'. Cuntenenu sustituzzioni di nomi di file webpack: [name] serà rimpiazzatu cù u nome di u puntu di input (in u nostru casu, questu game), a [contenthash] serà rimpiazzatu cù un hash di u cuntenutu di u schedariu. Facemu per ottimisimu u prugettu per l'hashing - pudete dì à i navigatori di cache in cache i nostri pacchetti JS indefinitu, perchè se un pacchettu cambia, allora u so nome di file cambia ancu (cambià contenthash). U risultatu finali serà u nome di u schedariu di vista game.dbeee76e91a97d0c7207.js.

u schedariu webpack.common.js hè u schedariu di cunfigurazione di basa chì avemu impurtatu in u sviluppu è e cunfigurazioni di u prughjettu finitu. Eccu un esempiu di cunfigurazione di sviluppu:

webpack.dev.js

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

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

Per efficienza, usemu in u prucessu di sviluppu webpack.dev.js, è cambia à webpack.prod.jsper ottimisà e dimensioni di u pacchettu quandu si implementa à a produzzione.

Configurazione locale

Aghju ricumandemu di stallà u prughjettu nantu à una macchina locale per pudè seguità i passi elencati in questu post. A cunfigurazione hè simplice: prima, u sistema deve esse stallatu Nodu и NPM. Dopu avete bisognu à fà

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

è site prontu per andà! Per inizià u servitore di sviluppu, basta à eseguisce

$ npm run develop

è andate à u navigatore web localhost: 3000. U servitore di sviluppu ricustruisce automaticamente i pacchetti JS è CSS cum'è u codice cambia - basta à rinfriscà a pagina per vede tutti i cambiamenti!

3. Punti d'entrata di u cliente

Andemu à u codice di u ghjocu stessu. Prima avemu bisognu di una pagina index.html, quandu visitate u situ, u navigatore vi carica prima. A nostra pagina serà abbastanza simplice:

index.html

Un esempiu di ghjocu .io  PLAY

Questu esempiu di codice hè statu simplificatu ligeramente per a chiarezza, è aghju da fà u listessu cù parechji di l'altri esempi di post. U codice sanu pò sempre esse vistu à Github.

Avemu:

  • Elementu di tela HTML5 (<canvas>) chì avemu aduprà per rende u ghjocu.
  • <link> per aghjunghje u nostru pacchettu CSS.
  • <script> per aghjunghje u nostru pacchettu Javascript.
  • Menu principale cù nome d'utilizatore <input> è u buttone PLAY (<button>).

Dopu avè caricatu a pagina di casa, u navigatore cumminciarà à eseguisce codice Javascript, partendu da u puntu di entrata JS file: 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);
  };
});

Questu pò sembra complicatu, ma ùn ci hè micca assai quì:

  1. Importazione di parechji altri schedari JS.
  2. Importazione CSS (cusì Webpack sà di includeli in u nostru pacchettu CSS).
  3. Lancia connect() per stabilisce una cunnessione cù u servitore è eseguisce downloadAssets() per scaricà l'imaghjini necessarii per rende u ghjocu.
  4. Dopu à a fine di a tappa 3 u menu principale hè visualizatu (playMenu).
  5. Configurazione di u gestore per appughjà u buttone "PLAY". Quandu u buttone hè pressatu, u codice inizializza u ghjocu è dice à u servitore chì simu pronti à ghjucà.

A "carne" principale di a nostra logica cliente-servitore hè in quelli schedari chì sò stati impurtati da u schedariu index.js. Avà avemu da cunsiderà tutti in ordine.

4. Scambiu di dati clienti

In questu ghjocu, usemu una biblioteca ben cunnisciuta per cumunicà cù u servitore socket.io. Socket.io hà supportu nativu sockets web, chì sò bè ​​adattati per a cumunicazione bidirezionale: pudemu mandà missaghji à u servitore и u servore pò mandà missaghji à noi nant'à a stessa cunnessione.

Averemu un schedariu src/client/networking.jschi s'occuperà tutti cumunicazione cù u servitore:

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

Stu codice hè statu ancu accurtatu un pocu per a chiarità.

Ci sò trè azzioni principali in stu schedariu:

  • Avemu pruvatu à cunnette cù u servitore. connectedPromise permessu solu quandu avemu stabilitu una cunnessione.
  • Se a cunnessione hè successu, registremu funzioni di callback (processGameUpdate() и onGameOver()) per i missaghji chì pudemu riceve da u servitore.
  • Esportamu play() и updateDirection()cusì chì altri schedari ponu aduprà.

5. Client Rendering

Hè ora di vede a stampa nantu à u screnu!

...ma prima di pudè fà quessa, avemu bisognu di scaricà tutte l'imaghjini (risorse) chì sò necessarii per questu. Scrivemu un gestore di 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(`Downloaded ${assetName}`);
      assets[assetName] = asset;
      resolve();
    };
    asset.src = `/assets/${assetName}`;
  });
}

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

A gestione di e risorse ùn hè micca cusì difficiule da implementà! L'idea principale hè di almacenà un oggettu assets, chì ligarà a chjave di u nome di u schedariu à u valore di l'ughjettu Image. Quandu a risorsa hè caricata, l'avemu guardata in un oggettu assets per un accessu rapidu in u futuru. Quandu ogni risorsa individuale serà permessa di scaricà (vale à dì, tutte e risorse), permettemu downloadPromise.

Dopu avè scaricatu e risorse, pudete inizià a rende. Comu dissi prima, per disegnà nantu à una pagina web, usemu HTML5 Canvas (<canvas>). U nostru ghjocu hè abbastanza simplice, per quessa, avemu solu bisognu di disegnà i seguenti:

  1. Sfondu
  2. Nave di ghjucadore
  3. Altri attori in u ghjocu
  4. Conchiglie

Eccu i frammenti impurtanti src/client/render.js, chì rende esattamente i quattru elementi elencati sopra:

rende.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);
}

Stu codice hè ancu scurciatu per a chiarità.

render() hè a funzione principale di stu schedariu. startRendering() и stopRendering() cuntrullà l'attivazione di u ciclu di rendering à 60 FPS.

Implementazioni concrete di funzioni individuali d'aiutu di rendering (es. renderBullet()) ùn sò micca cusì impurtanti, ma quì hè un esempiu simplice:

rende.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 chì avemu aduprà u metudu getAsset(), chì era vistu prima in asset.js!

Sè site interessatu à amparà nantu à altri aiutanti di rendering, allora leghjite u restu. src/client/render.js.

6. Client input

Hè ora di fà un ghjocu ghjucable! U schema di cuntrollu serà assai simplice: per cambià a direzzione di u muvimentu, pudete aduprà u mouse (in un computer) o toccu u screnu (in un dispositivu mobile). Per implementà questu, avemu da esse registratu Ascoltatori di eventi per l'eventi Mouse è Touch.
Serà cura di tuttu questu 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() sò Event Listeners chì chjamanu updateDirection() (di networking.js) quandu si verifica un avvenimentu di input (per esempiu, quandu u mouse hè spustatu). updateDirection() gestisce a messageria cù u servitore, chì gestisce l'avvenimentu di input è aghjurnà u statu di u ghjocu in cunseguenza.

7. Status Client

Sta rùbbrica hè u più difficiule in a prima parte di u post. Ùn vi scuraggiate s'ellu ùn capite micca a prima volta chì leghjite ! Pudete ancu saltà è vultà dopu.

L'ultimu pezzu di u puzzle necessariu per compie u codice cliente / servitore hè statu. Ricurdativi di u snippet di codice da a sezione Client Rendering?

rende.js

import { getCurrentState } from './state';

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

  // Do the rendering
  // ...
}

getCurrentState() duveria esse capace di dà u statu attuale di u ghjocu in u cliente in ogni puntu in u tempu basatu annantu à l'aghjurnamenti ricevuti da u servitore. Eccu un esempiu di un aghjurnamentu di ghjocu chì u servitore pò mandà:

{
  "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 aghjurnamentu di ghjocu cuntene cinque campi identici:

  • t: Tempu di u servitore chì indica quandu sta aghjurnazione hè stata creata.
  • me: Informazioni nantu à u ghjucatore chì riceve sta aghjurnazione.
  • autri: Una serie di informazioni nantu à altri ghjucatori chì participanu à u listessu ghjocu.
  • balle: una serie di informazioni nantu à i prughjetti in u ghjocu.
  • leaderboard: Dati di classificazione attuale. In questu post, ùn li cunsideremu micca.

7.1 Statu di u cliente ingenu

Implementazione ingenua getCurrentState() pò solu rinvià direttamente i dati di l'aghjurnamentu di u ghjocu più recente ricevutu.

naive-state.js

let lastGameUpdate = null;

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

export function getCurrentState() {
  return lastGameUpdate;
}

Bellu è chjaru! Ma s'ellu era cusì simplice. Unu di i mutivi di sta implementazione hè problematica: limita a freccia di fotogramma di rendering à a freccia di clock di u servitore.

Frame Rate: numeru di frames (vale à dì chjamate render()) per seconda, o FPS. I ghjochi di solitu si sforzanu di ottene almenu 60 FPS.

Tick ​​Rate: A frequenza à quale u servitore manda l'aghjurnamenti di u ghjocu à i clienti. Hè spessu più bassu di a freccia di quadru. In u nostru ghjocu, u servitore corre à una freccia di 30 ciculi per seconda.

Se rendemu solu l'ultima aghjurnazione di u ghjocu, allora l'FPS essenzialmente ùn passerà mai più di 30, perchè ùn avemu mai più di 30 aghjurnamenti per seconda da u servitore. Ancu s'è no chjamemu render() 60 volte per seconda, allora a mità di sti chjamati ridisegnaranu a stessa cosa, essenzialmente senza fà nunda. Un altru prublema cù l'implementazione ingenua hè chì propensu à i ritardi. Cù una velocità Internet ideale, u cliente riceverà una aghjurnazione di u ghjocu esattamente ogni 33ms (30 per seconda):

Crià un Multiplayer .io Web Game
Sfortunatamente, nunda hè perfettu. Una stampa più realistica seria:
Crià un Multiplayer .io Web Game
L'implementazione ingenua hè praticamente u peghju casu quandu si tratta di latenza. Se un aghjurnamentu di u ghjocu hè ricevutu cù un ritardu di 50 ms, allora bancarelle di i clienti un extra 50ms perchè hè sempre rendendu u statu di u ghjocu da l'aghjurnamentu precedente. Pudete imaginà quantu hè scomodu per u ghjucatore: u frenu arbitrariu farà chì u ghjocu si senti saccu è instabile.

7.2 Migliuratu u statu di u cliente

Avemu da fà alcune migliure à l'implementazione ingenua. Prima, avemu aduprà ritardu di rende per 100 ms. Questu significa chì u statu "attuali" di u cliente sempre ritardarà u statu di u ghjocu nantu à u servitore per 100 ms. Per esempiu, se u tempu nantu à u servitore hè 150, allura u cliente rende u statu chì u servitore era in quellu tempu 50:

Crià un Multiplayer .io Web Game
Questu ci dà un buffer di 100 ms per sopravvive à i tempi di aghjurnamentu di u ghjocu imprevisible:

Crià un Multiplayer .io Web Game
U pagamentu per questu serà permanente ritardo di input per 100 ms. Questu hè un sacrifiziu minore per un ghjocu liscio - a maiò parte di i ghjucatori (in particulare i ghjucatori casuali) ùn anu mancu nutatu stu ritardu. Hè assai più faciule per e persone per aghjustà à una latenza constante di 100 ms cà di ghjucà cù una latenza imprevisible.

Pudemu ancu aduprà una altra tecnica chjamata predizione di u cliente, chì face un bonu travagliu per riduce a latenza percepita, ma ùn serà micca cupertu in questu postu.

Un altru migliuramentu chì usemu hè interpolazione lineare. A causa di u lag di rendering, simu di solitu almenu una aghjurnazione davanti à u tempu attuale in u cliente. Quandu chjamatu getCurrentState(), pudemu eseguisce interpolazione lineare trà l'aghjurnamenti di u ghjocu ghjustu prima è dopu l'ora attuale in u cliente:

Crià un Multiplayer .io Web Game
Questu risolve u prublema di freccia di frames: pudemu avà rende frames unichi à qualsiasi frame rate chì vulemu!

7.3 Implementazione di u statu di u cliente rinfurzatu

Esempiu di implementazione in src/client/state.js usa tramindui lag di rendering è interpolazione lineare, ma micca per longu. Dividemu u codice in dui parti. Eccu u primu:

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

U primu passu hè di capisce ciò chì currentServerTime(). Comu avemu vistu prima, ogni aghjurnamentu di ghjocu include un timestamp di u servitore. Vulemu aduprà a latenza di rende per rende l'imaghjini 100ms daretu à u servitore, ma ùn sapemu mai l'ora attuale nantu à u servitore, perchè ùn pudemu micca sapè quantu tempu hà pigliatu per qualcunu di l'aghjurnamenti per ghjunghje à noi. L'Internet hè imprevisible è a so velocità pò varià assai!

Per attruvà stu prublema, pudemu usà una apprussimazioni raghjone: noi finta chì a prima aghjurnazione hè ghjunta istantaneamente. S'ellu era veru, allora sapemu u tempu di u servitore in questu mumentu particulare! Guardemu u timestamp di u servitore firstServerTimestamp è mantene a nostra lucale (cliente) timestamp in u stessu mumentu in gameStart.

Oh aspetta. Ùn deve esse u tempu di u servitore = u tempu di u cliente ? Perchè distinguemu trà "server timestamp" è "client timestamp"? Questa hè una bella pregunta! Risulta chì ùn sò micca listessa cosa. Date.now() riturnerà diversi timestamps in u cliente è u servitore, è dipende di fatturi lucali à queste macchine. Ùn mai assume chì i timestamps seranu listessi in tutte e macchine.

Avà capimu ciò chì face currentServerTime(): torna u timestamp di u servitore di u tempu di rendering attuale. In altre parolle, questu hè l'ora attuale di u servitore (firstServerTimestamp <+ (Date.now() - gameStart)) minus ritardu di rende (RENDER_DELAY).

Avà fighjemu un ochju à cumu gestionemu l'aghjurnamenti di u ghjocu. Quandu hè ricevutu da u servitore di l'aghjurnamentu, hè chjamatu processGameUpdate()è salvemu a nova aghjurnazione à un array gameUpdates. Allora, per verificà l'utilizazione di a memoria, sguassemu tutte e vechji aghjurnamenti prima aghjurnamentu di basaperchè ùn ne avemu più bisognu.

Cosa hè una "aghjurnamentu di basa"? Questu a prima aghjurnazione truvamu in retrocede da l'ora attuale di u servitore. Ricurdativi di stu schema?

Crià un Multiplayer .io Web Game
L'aghjurnamentu di u ghjocu direttamente à a manca di "Client Render Time" hè l'aghjurnamentu di basa.

A cosa serve l'aghjurnamentu di basa? Perchè pudemu abbandunà l'aghjurnamenti à a basa? Per capisce questu, andemu infine cunzidira 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),
    };
  }
}

Trattemu trè casi:

  1. base < 0 significa chì ùn ci sò micca aghjurnamenti finu à u tempu di rendering attuale (vede l'implementazione sopra getBaseUpdate()). Questu pò accade ghjustu à l'iniziu di u ghjocu per via di u lag di rendering. In questu casu, usemu l'ultima aghjurnazione ricevuta.
  2. base hè l'ultima aghjurnazione chì avemu. Questu pò esse duvuta à un ritardu di rete o à una cunnessione Internet povera. In questu casu, avemu ancu aduprà l'ultima aghjurnazione chì avemu.
  3. Avemu una aghjurnazione sia prima sia dopu à u tempu di rende attuale, cusì pudemu interpolà!

Tuttu ciò chì resta in state.js hè una implementazione di l'interpolazione lineale chì hè simplice (ma noiosa) matematica. Se vulete scopre da voi stessu, allora apre state.js nantu Github.

Part 2. Backend Server

In questa parte, avemu da piglià un ochju à u backend Node.js chì cuntrolla u nostru Esempiu di ghjocu .io.

1. Puntu di entrata di u servitore

Per gestisce u servitore web, avemu aduprà un framework web populari per Node.js chjamatu EXPRESS. Serà cunfiguratu da u nostru schedariu di u puntu di ingressu di u servitore 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}`);

Ricurdativi chì in a prima parte avemu discututu Webpack? Questu hè induve useremu e nostre cunfigurazioni Webpack. Li useremu in dui maneri:

  • Aduprate webpack-dev-middleware per ricustruisce automaticamente i nostri pacchetti di sviluppu, o
  • trasferimentu static cartulare dist/, in quale Webpack scriverà i nostri fugliali dopu a custruzzione di produzzione.

Un altru compitu impurtante server.js hè di stallà u servitore socket.iochì si cunnetta solu à u servitore 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);
});

Dopu avè stabilitu successu una cunnessione socket.io à u servitore, avemu stallatu gestori di l'avvenimenti per u novu socket. I gestori di l'avvenimenti trattanu i missaghji ricevuti da i clienti delegandu à un oggettu 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);
}

Creemu un ghjocu .io, cusì avemu bisognu di una sola copia Game ("Game") - tutti i ghjucatori ghjucanu in a listessa arena! In a sezione dopu, avemu da vede cumu funziona sta classa. Game.

2. Servitori di ghjocu

Class Game cuntene a logica più impurtante in u latu di u servitore. Hà duie funzioni principali: gestione di i ghjucatori и simulazione di ghjocu.

Cuminciamu cù u primu compitu, a gestione di i ghjucatori.

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 questu ghjocu, avemu da identificà i ghjucatori per u campu id u so socket.io socket (se vi cunfundite, allora vultate à server.js). Socket.io stessu assigna à ogni socket un unicu iddunque ùn avemu micca bisognu di preoccupassi. U chjamaraghju ID di u ghjucatore.

Cù questu in mente, scopremu e variabili di istanza in una classe Game:

  • sockets hè un ughjettu chì liga l'ID di u ghjucatore à u socket chì hè assuciatu cù u lettore. Ci permette di accede à i sockets da i so ID di ghjucadore in un tempu constante.
  • players hè un ughjettu chì liga l'ID di u lettore à u codice>Ughjettu di u lettore

bullets hè una serie di oggetti Bullet, chì ùn hà micca un ordine definitu.
lastUpdateTime hè u timestamp di l'ultima volta chì u ghjocu hè statu aghjurnatu. Videremu cumu si usa prestu.
shouldSendUpdate hè una variabile ausiliaria. Videremu ancu u so usu prestu.
Metodi addPlayer(), removePlayer() и handleInput() senza bisognu di spiegà, sò usati in server.js. Sè avete bisognu di rinfriscà a vostra memoria, torna un pocu più altu.

Ultima linea constructor() cumencia ciclu di aghjurnamentu ghjochi (cù una frequenza di 60 aghjurnamenti / 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;
    }
  }

  // ...
}

Metu update() cuntene forse u pezzu più impurtante di logica di u servitore. Eccu ciò chì face, in ordine:

  1. Calcula quantu tempu dt passatu da l'ultimu update().
  2. Refreshes ogni projectile è li distrugge se ne necessariu. Videremu l'implementazione di sta funziunalità dopu. Per avà, ci hè abbastanza per sapè chì bullet.update() torna truese u projectile deve esse distruttu (scesa da l'arena).
  3. Aghjurnate ogni ghjucatore è genera un projectile se ne necessariu. Videremu ancu sta implementazione più tardi - player.update() pò rinvià un oggettu Bullet.
  4. Cuntrolla per scontri trà i prughjetti è i ghjucatori cù applyCollisions(), chì torna una serie di prughjetti chì colpisce i ghjucatori. Per ogni proiettile restituitu, aumentemu i punti di u ghjucatore chì l'hà sparatu (usendu player.onDealtDamage()) è poi sguassate u projectile da u array bullets.
  5. Notifica è distrugge tutti i ghjucatori uccisi.
  6. Invia un aghjurnamentu di u ghjocu à tutti i ghjucatori ogni siconda volte quandu chjamati update(). Questu ci aiuta à seguità a variabile ausiliaria citata sopra. shouldSendUpdate. As update() chjamatu 60 volte/s, mandemu l'aghjurnamenti di u ghjocu 30 volte/s. Cusì, frequenza di u clock u clock di u servitore hè 30 clocks / s (avemu parlatu di freti di clock in a prima parte).

Perchè mandà solu l'aghjurnamenti di u ghjocu à traversu u tempu ? Per salvà u canali. 30 aghjurnamenti di ghjocu per secondu hè assai!

Perchè micca solu chjamate update() 30 volte per seconda? Per migliurà a simulazione di ghjocu. U più spessu chjamatu update(), u più precisa serà a simulazione di ghjocu. Ma ùn lasciate micca troppu purtatu cù u numeru di sfide. update(), perchè questu hè un compitu di calculu caru - 60 per seconda hè abbastanza.

U restu di a classe Game cunsiste in i metudi d'aiutu utilizati 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() abbastanza simplice - ordina i ghjucatori per puntuazione, piglia i primi cinque, è torna u nome d'utilizatore è u puntuatu per ognunu.

createUpdate() adupratu in update() per creà l'aghjurnamenti di u ghjocu chì sò distribuiti à i ghjucatori. U so compitu principale hè di chjamà metudi serializeForUpdate()implementatu per e classi Player и Bullet. Nota chì solu passa dati à ogni jocaturi circa u più vicinu ghjucatori è prughjetti - ùn ci hè bisognu di trasmette infurmazioni nantu à l'uggetti di u ghjocu chì sò luntanu da u ghjucatore!

3. Ughjetti di ghjocu nantu à u servitore

In u nostru ghjocu, i prughjetti è i ghjucatori sò in realtà assai simili: sò oggetti di ghjocu astratti, tondi è mobili. Per prufittà di sta similitudine trà i ghjucatori è i prughjetti, cuminciamu à implementà a classa di basa 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,
    };
  }
}

Ùn ci hè nunda di complicatu chì passa quì. Questa classa serà un bonu puntu di ancora per l'estensione. Videmu cumu a classa 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;
  }
}

Реализация Bullet assai cortu! Avemu aghjustatu Object solu e seguenti estensioni:

  • Utilizà un pacchettu shortid per a generazione aleatoria id projectile.
  • Aghjunghjendu un campu parentIDcusì chì pudete seguità u ghjucatore chì hà creatu stu projectile.
  • Aghjunghjendu un valore di ritornu à update(), chì hè uguale à truese u projectile hè fora di l'arena (ricurdate chì avemu parlatu di questu in l'ultima sezione?).

Passemu à 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 ghjucatori sò più cumplessi cà i prughjetti, cusì uni pochi di più campi deve esse guardatu in questa classe. U so metudu update() face assai travagliu, in particulare, torna u prughjettu novu creatu s'ellu ùn ci hè nimu fireCooldown (ricurdate chì avemu parlatu di questu in a sezione precedente?). Si estende ancu u metudu serializeForUpdate(), perchè avemu bisognu di include campi supplementari per u ghjucatore in l'aghjurnamentu di u ghjocu.

Avè una classe di basa Object - un passu impurtante per evità di ripetiri codice. Per esempiu, senza classi Object ogni ughjettu di ghjocu deve avè a listessa implementazione distanceTo(), è copià-incolla tutte queste implementazioni in parechji schedarii seria un incubo. Questu hè particularmente impurtante per i grandi prughjetti.quandu u numeru di espansione Object e classi sò in crescita.

4. Collision detection

L'unica cosa chì ci resta hè di ricunnosce quandu i prughjetti culpiscenu i ghjucatori ! Ricurdativi di stu pezzu di codice da u metudu update() in classe Game:

ghjocu.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),
    );

    // ...
  }
}

Avemu bisognu di implementà u metudu applyCollisions(), chì torna tutti i prughjetti chì chjappà i ghjucatori. Per furtuna, ùn hè micca cusì difficiule di fà perchè

  • Tutti l'uggetti in collisione sò cerchi, è questu hè a forma più simplice per implementà a rilevazione di collisione.
  • Avemu digià un metudu distanceTo(), chì avemu implementatu in a sezione precedente in a classe Object.

Eccu ciò chì a nostra implementazione di a rilevazione di collisione pare:

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

Sta semplice rilevazione di collisione hè basatu annantu à u fattu chì dui circles scontranu si a distanza trà i so centri hè menu di a somma di i so radii. Eccu u casu induve a distanza trà i centri di dui circles hè esattamente uguali à a summa di i so radii:

Crià un Multiplayer .io Web Game
Ci hè un paru di più aspetti da cunsiderà quì:

  • U projectile ùn deve micca colpi à u ghjucatore chì l'hà creatu. Questu pò esse ottenutu per paragunà bullet.parentID с player.id.
  • U projectile deve chjappà solu una volta in u casu limitante di parechji ghjucatori chì si scontranu à u stessu tempu. Avemu da risolve stu prublema cù l'operatore break: appena u ghjucatore chì scontra cù u projectile hè truvatu, fermamu a ricerca è andemu à u prossimu projectile.

U Vaghjime

Eccu tuttu! Avemu cupertu tuttu ciò chì avete bisognu di sapè per creà un ghjocu web .io. Chì ci hè dopu ? Custruite u vostru propiu ghjocu .io!

Tuttu u codice di mostra hè apertu è publicatu Github.

Source: www.habr.com

Add a comment