Erstellt e Multiplayer Webspill am .io Genre

Erstellt e Multiplayer Webspill am .io Genre
Verëffentlecht am Joer 2015 Agar.io gouf de Virgänger vun engem neie Genre games.io, deem seng Popularitéit zënterhier staark gewuess ass. Ech hunn d'Erhéijung vun der Popularitéit vun .io Spiller selwer erlieft: an de leschten dräi Joer hunn ech erstallt a verkaf zwee Spiller an dësem Genre..

Am Fall wou Dir nach ni vun dëse Spiller héieren hutt, si si gratis, Multiplayer Web Spiller déi einfach ze spillen sinn (kee Kont erfuerderlech). Si Pit normalerweis vill Géigespiller Spiller an engem Arène. Aner berühmte .io Spiller: Slither.io и Diep.io.

An dësem Post wäerte mir erausfannen wéi erstellt en .io Spill vun Null. Fir dëst ze maachen, wäert nëmme Wëssen iwwer Javascript genuch sinn: Dir musst Saache wéi Syntax verstoen ES6, Schlësselwuert this и Verspriechen. Och wann Dir Javascript net perfekt kennt, kënnt Dir nach ëmmer de gréissten Deel vum Post verstoen.

Beispill vun engem .io Spill

Fir Training Hëllef wäerte mir op Beispill Spill .io. Probéiert et ze spillen!

Erstellt e Multiplayer Webspill am .io Genre
D'Spill ass ganz einfach: Dir kontrolléiert e Schëff an enger Arena mat anere Spiller. Äert Schëff brennt automatesch Projektilen an Dir probéiert aner Spiller ze schloen, während Dir hir Projektilen vermeit.

1. Kuerz Iwwerbléck / Projet Struktur

Ech recommandéieren download Quellcode Beispill Spill fir datt Dir mech verfollege kënnt.

D'Beispill benotzt déi folgend:

  • Express ass de populärste Webframework fir Node.js deen de Webserver vum Spill geréiert.
  • socket.io - Websocket Bibliothéik fir Datenaustausch tëscht dem Browser an dem Server.
  • Webpack - Modul Manager. Dir kënnt iwwer liesen firwat Dir Webpack benotzt hei.

Dëst ass wéi d'Projektverzeichnisstruktur ausgesäit:

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

ëffentlech/

Alles ass am Dossier public/ gëtt statesch vum Server iwwerdroen. IN public/assets/ enthält Biller déi vun eisem Projet benotzt ginn.

src /

All Quellcode ass am Dossier src/. Titelen client/ и server/ fir sech schwätzen an shared/ enthält eng Konstantedatei importéiert vum Client a vum Server.

2. Assemblée / Projet Parameteren

Wéi uewen ernimmt, benotze mir e Modulmanager fir de Projet ze bauen Webpack. Loosst eis eis Webpack Konfiguratioun kucken:

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

Déi wichtegst Linnen hei sinn déi folgend:

  • src/client/index.js ass den Entréespunkt vum Javascript (JS) Client. Webpack fänkt vun hei un a kuckt rekursiv no aner importéiert Dateien.
  • Den Output JS vun eisem Webpack Build wäert am Verzeechnes sinn dist/. Ech nennen dëse Fichier eis JS Package.
  • Mir benotzen Babel, a besonnesch d'Konfiguratioun @babel/preset-env fir eise JS Code fir eeler Browser ze transpiléieren.
  • Mir benotzen e Plugin fir all CSS, déi vu JS-Dateien referenzéiert gëtt, ze extrahieren an op eng Plaz ze kombinéieren. Ech wäert et eis nennen CSS Package.

Dir hutt vläicht komesch Package Dateinumm gemierkt '[name].[contenthash].ext'. Si enthalen Dateinumm Ersatz Webpack: [name] gëtt mam Numm vum Input Punkt ersat (an eisem Fall ass et game), an [contenthash] gëtt duerch en Hash vum Dateiinhalt ersat. Mir maachen dat fir optiméiert de Projet fir Hashing - mir kënnen de Browser soen eis JS Packagen onbestëmmt ze cache well wann e Package ännert, ännert säin Dateinumm och (Ännerungen contenthash). Dat fäerdegt Resultat wäert den Dateinumm vun der Vue sinn game.dbeee76e91a97d0c7207.js.

Fichier webpack.common.js ass d'Basis Konfiguratiounsdatei déi mir an d'Entwécklung a fäerdeg Projetskonfiguratiounen importéieren. Zum Beispill, hei ass d'Entwécklungskonfiguratioun:

webpack.dev.js

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

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

Fir Effizienz benotze mir am Entwécklungsprozess webpack.dev.js, a schalt op webpack.prod.js, fir Packagegréissten ze optimiséieren wann se an d'Produktioun ofgesat ginn.

Lokal Setup

Ech recommandéieren de Projet op Ärer lokaler Maschinn z'installéieren sou datt Dir d'Schrëtt an dësem Post verfollege kënnt. Setup ass einfach: éischtens muss de System hunn Node sinn и NPM. Als nächst musst Dir maachen

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

an Dir sidd prett ze goen! Fir den Entwécklungsserver unzefänken, lafen einfach

$ npm run develop

a gitt op Äre Webbrowser localhost: 3000. Den Entwécklungsserver wäert d'JS- an CSS-Paketen automatesch opbauen wéi Code Ännerungen optrieden - just d'Säit erfrëscht fir all d'Ännerungen ze gesinn!

3. Client Entrée Punkten

Loosst eis op de Spillcode selwer erofgoen. Als éischt brauche mir eng Säit index.html, wann Dir de Site besicht, lued de Browser et als éischt. Eis Säit wäert ganz einfach sinn:

index.html

E Beispill .io Spill  SPILLEN

Dëse Code Beispill gouf liicht vereinfacht fir Kloerheet, an ech wäert datselwecht mat villen anere Beispiller am Post maachen. Dir kënnt ëmmer de ganze Code kucken op Github.

Mir hunn:

  • HTML5 Canvas Element (<canvas>), déi mir benotze fir d'Spill ze maachen.
  • <link> fir eisen CSS Package derbäi ze ginn.
  • <script> fir eise Javascript Package ze addéieren.
  • Haaptmenü mat Benotzernumm <input> an den "PLAY" Knäppchen (<button>).

Wann d'Homepage lued, fänkt de Browser Javascript Code auszeféieren, ugefaange mat der Entréespunkt JS Datei: 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);
  };
});

Dëst kléngt vläicht komplizéiert, awer et ass tatsächlech net vill geschitt hei:

  1. Import e puer aner JS Dateien.
  2. Import CSS (also Webpack weess se an eisem CSS Package ze enthalen).
  3. Lancéiere connect() fir eng Verbindung mam Server opzebauen an unzefänken downloadAssets() fir d'Biller erofzelueden déi néideg sinn fir d'Spill ze maachen.
  4. Nom Ofschloss vun der 3 Haaptmenü gëtt ugewisen (playMenu).
  5. Ariichten der "PLAY" Knäppchen klickt Handler. Wann de Knäppchen gedréckt ass, initialiséiert de Code d'Spill a seet dem Server datt mir prett sinn ze spillen.

Den Haapt "Fleesch" vun eiser Client-Server Logik ass an deene Fichieren déi vun der Datei importéiert goufen index.js. Elo wäerte mir se all an Uerdnung kucken.

4. Austausch vun Client Daten

An dësem Spill benotze mir eng bekannte Bibliothéik fir mat dem Server ze kommunizéieren socket.io. Socket.io huet gebaut-an Ënnerstëtzung WebSockets, déi gutt fir zwee-Wee Kommunikatioun gëeegent sinn: mir kënnen Messagen un de Server schécken и de Server kann Messagen un eis iwwer déi selwecht Verbindung schécken.

Mir wäerten eng Datei hunn src/client/networking.jsdee këmmert sech ëm jiddereen Kommunikatioun mam 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);
};

Dëse Code ass och liicht verkierzt fir Kloerheet.

Et ginn dräi Haapt Saache geschitt an dësem Fichier:

  • Mir probéieren op de Server ze verbannen. connectedPromise nëmmen erlaabt wann mir eng Verbindung etabléiert hunn.
  • Wann d'Verbindung erfollegräich ass, registréiere mir Callback Funktiounen (processGameUpdate() и onGameOver()) fir Messagen déi mir vum Server kréien.
  • Mir exportéieren play() и updateDirection()sou datt aner Dateie se benotze kënnen.

5. Client Render-

Et ass Zäit d'Bild um Écran ze weisen!

...mee ier mer dat kënne maachen, musse mir all d'Biller (Ressourcen) eroflueden, déi dofir gebraucht ginn. Loosst eis e Ressourcemanager schreiwen:

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

Ressourcemanagement ass net sou schwéier ze realiséieren! Den Haaptpunkt ass en Objet ze späicheren assets, deen den Dateinummschlëssel un den Objektwäert bindt Image. Wann d'Ressource gelueden ass, späichere mir et op en Objet assets fir séier Empfang an Zukunft. Wéini gëtt d'Download vun all eenzelne Ressource erlaabt (dat ass, download all dat Ressourcen), mir erlaben downloadPromise.

Nodeems Dir d'Ressourcen erofgelueden hutt, kënnt Dir Rendering ufänken. Wéi virdru gesot, fir op enger Websäit ze zéien déi mir benotzen HTML5 Canvas (<canvas>). Eist Spill ass ganz einfach, also brauche mir nëmmen déi folgend ze maachen:

  1. Hannergrond
  2. Spiller Schëff
  3. Aner Spiller am Spill
  4. Muschelen

Hei sinn déi wichteg Stécker src/client/render.js, déi genee déi véier Punkten uewen opgezielt zéien:

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

Dëse Code ass och fir Kloerheet verkierzt.

render() ass d'Haaptfunktioun vun dëser Datei. startRendering() и stopRendering() Kontrolléiert d'Aktivatioun vum Rendering-Zyklus bei 60 FPS.

Spezifesch Implementatioune vun eenzelne Rendering Helper Funktiounen (zum Beispill renderBullet()) sinn net sou wichteg, awer hei ass en einfacht Beispill:

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

Notéiert datt mir d'Method benotzen getAsset(), déi virdru gesinn an asset.js!

Wann Dir interesséiert sidd fir aner Rendering-Helferfunktiounen ze entdecken, da liest de Rescht vun src/client/render.js.

6. Client Input

Et ass Zäit e Spill ze maachen spillbar! De Kontrollschema wäert ganz einfach sinn: fir d'Bewegungsrichtung z'änneren, kënnt Dir d'Maus benotzen (op engem Computer) oder den Ecran (op engem mobilen Apparat) beréieren. Fir dëst ëmzesetzen wäerte mir Iech registréieren Event Nolauschterer fir Maus an Touch Eventer.
Wäert all dëst këmmeren 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() sinn Event Nolauschterer déi ruffen updateDirection() (vun networking.js) wann en Input Event geschitt (zum Beispill wann d'Maus bewegt gëtt). updateDirection() beschäftegt sech mam Austausch vu Messagen mam Server, deen den Input Event veraarbecht an de Spillzoustand deementspriechend aktualiséiert.

7. Client Status

Dës Sektioun ass déi schwéierst am éischten Deel vum Post. Sidd net decouragéiert wann Dir et net versteet déi éischte Kéier wann Dir et liest! Dir kënnt et souguer iwwersprangen a spéider drop zréckkommen.

Dat lescht Stéck vum Puzzel, deen néideg ass fir de Client-Server Code ofzeschléissen ass Staat. Erënnert Dir Iech un de Code Snippet aus der Client Rendering Sektioun?

render.js

import { getCurrentState } from './state';

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

  // Do the rendering
  // ...
}

getCurrentState() soll fäheg sinn eis mat der aktueller Spill Staat am Client ze bidden zu all Moment baséiert op Aktualiséierungen vum Server kritt. Hei ass e Beispill vun engem Spillupdate deen de Server schéckt:

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

All Spillupdate enthält fënnef identesch Felder:

  • t: Server Zäitstempel weist wéini dësen Update erstallt gouf.
  • me: Informatioun iwwer de Spiller deen dësen Update kritt.
  • anerer: Eng ganz Rëtsch vun Informatioun iwwer aner Spiller déi am selwechte Spill deelhuelen.
  • Bullets: Array vun Informatioun iwwer Projektilen am Spill.
  • Leaderbicher: Aktuell Leaderboard Daten. Mir wäerten se net an dësem Post Rechnung huelen.

7.1 Client naiv Staat

Naiv Ëmsetzung getCurrentState() kann nëmmen direkt Daten aus dem kierzlechst kritt Spillaktualiséierung zréckginn.

naiv-state.js

let lastGameUpdate = null;

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

export function getCurrentState() {
  return lastGameUpdate;
}

Schéin a kloer! Awer wann et nëmmen esou einfach wier. Ee vun de Grënn fir dës Ëmsetzung problematesch: et limitéiert de Rendering Frame Taux op d'Server Auergeschwindegkeet.

Frame Taux: Zuel vu Frames (dh Uruff render()) pro Sekonn oder FPS. Games beméien normalerweis op d'mannst 60 FPS z'erreechen.

Tick ​​Taux: D'Frequenz mat där de Server Spillupdates u Clienten schéckt. Et ass dacks manner wéi de Frame Taux. An eisem Spill leeft de Server mat 30 Ticks pro Sekonn.

Wa mir just déi lescht Spillupdate maachen, da wäert d'FPS wesentlech ni fäeg sinn 30 ze iwwerschreiden well mir kréien ni méi wéi 30 Aktualiséierungen pro Sekonn vum Server. Och wa mir ruffen render() 60 Mol pro Sekonn, da wäert d'Halschent vun dësen Uruff einfach déiselwecht Saach nei zéien, am Fong näischt maachen. Anere Problem mat enger naiv Ëmsetzung ass, datt et ënnerleien zu Verspéidungen. Mat idealer Internetgeschwindegkeet kritt de Client e Spillupdate genee all 33 ms (30 pro Sekonn):

Erstellt e Multiplayer Webspill am .io Genre
Leider ass näischt perfekt. E méi realistescht Bild wier:
Erstellt e Multiplayer Webspill am .io Genre
Eng naiv Ëmsetzung ass zimlech de schlëmmste Fall wann et ëm d'Latenz geet. Wann e Spillaktualiséierung mat enger 50ms Verzögerung kritt gëtt, dann de Client gëtt verlangsamt duerch eng extra 50ms well et nach ëmmer de Spillzoustand vum fréiere Update rendert. Dir kënnt Iech virstellen wéi onbequem dëst fir de Spiller ass: duerch arbiträr Verlängerungen wäert d'Spill ruckeleg an onbestänneg schéngen.

7.2 Verbessert Client Staat

Mir wäerten e puer Verbesserungen un der naiv Ëmsetzung maachen. Als éischt benotze mir Render- Verspéidung op 100 ms. Dëst bedeit datt den "aktuellen" Zoustand vum Client ëmmer 100ms hannert dem Spillzoustand um Server ass. Zum Beispill, wann de Server Zäit ass 150, da gëtt de Client den Zoustand an deem de Server zu där Zäit war 50:

Erstellt e Multiplayer Webspill am .io Genre
Dëst gëtt eis en 100ms Puffer fir den onberechenbaren Timing vu Spillupdates ze iwwerliewen:

Erstellt e Multiplayer Webspill am .io Genre
De Präis fir dëst wäert permanent ginn Input Lag op 100 ms. Dëst ass e klengt Opfer fir glat Spillsaach - déi meescht Spiller (besonnesch Casual) wäerten dës Verspéidung net emol bemierken. Et ass vill méi einfach fir d'Leit sech un eng konstant 100ms latency unzepassen wéi mat onberechenbarer latency ze spillen.

Mir kënnen eng aner Technik benotzen genannt "Client-side Prognosen", Wat eng gutt Aarbecht mécht fir d'perceptéiert latency ze reduzéieren, awer wäert net an dësem Post diskutéiert ginn.

Eng aner Verbesserung déi mir benotzen ass linear Interpolatioun. Wéinst Rendering Lag, mir sinn normalerweis op d'mannst een Update virun der aktueller Zäit am Client. Wann genannt getCurrentState(), kënne mir erfëllen linear Interpolatioun tëscht Spillupdates direkt virun an no der aktueller Zäit am Client:

Erstellt e Multiplayer Webspill am .io Genre
Dëst léist de Frame-Rate-Problem: mir kënnen elo eenzegaarteg Frames zu all Frame-Taux déi mir brauchen!

7.3 Ëmsetzung vun engem verbessert Client Staat

Beispill Ëmsetzung an src/client/state.js benotzt souwuel Rendering Verspéidung a linear Interpolatioun, awer dëst dauert net laang. Loosst eis de Code an zwee Deeler briechen. Hei ass déi éischt:

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

Dat éischt wat Dir maache musst ass erauszefannen wat et mécht currentServerTime(). Wéi mir virdru gesinn hunn, enthält all Spillupdate e Server Zäitstempel. Mir wëllen Render- latency benotzen fir d'Bild 100ms hannert dem Server ze maachen, awer mir wäerten ni déi aktuell Zäit op de Server wëssen, well mir kënnen net wëssen wéi laang et gedauert huet fir eng vun den Updates fir eis z'erreechen. Den Internet ass onberechenbar a seng Geschwindegkeet ka vill variéieren!

Fir dëst Problem ëmzegoen, kënne mir eng raisonnabel Approximatioun benotzen: mir loosst eis maachen wéi wann den éischten Update direkt ukomm ass. Wann dat stëmmt, da wësse mir d'Serverzäit an deem bestëmmte Moment! Mir späicheren de Server Zäitstempel an firstServerTimestamp a retten eis lokal (Client) Zäitstempel am selwechte Moment an gameStart.

Oh, waart eng Minutt. Soll et net Zäit um Server sinn = Zäit um Client? Firwat ënnerscheede mir tëscht "Server Zäitstempel" an "Client Zäitstempel"? Dëst ass eng super Fro! Et stellt sech eraus datt dës net déiselwecht Saach sinn. Date.now() gëtt verschidden Zäitstempel am Client an Server zréck an dëst hänkt op Faktoren lokal fir dës Maschinnen. Ni dovun ausgoen, datt Zäitstempel op all Maschinnen déi selwecht ginn.

Elo verstinn mir wat et mécht currentServerTime(): et geet zréck Server Zäitstempel vun der aktueller Renderingzäit. An anere Wierder, dëst ass déi aktuell Serverzäit (firstServerTimestamp <+ (Date.now() - gameStart)) minus Rendering Verzögerung (RENDER_DELAY).

Loosst eis elo kucken wéi mir Spillupdates behandelen. Wann en Update vum Server kritt gëtt, gëtt et genannt processGameUpdate(), a mir späicheren den neien Update op eng Array gameUpdates. Dann, fir d'Erënnerungsverbrauch z'iwwerpréiwen, hu mir all al Aktualiséierungen ewechgeholl Basis Updatewell mir se net méi brauchen.

Wat ass e "Käraktualiséierung"? Dëst déi éischt Aktualiséierung fanne mir andeems Dir vun der aktueller Serverzäit zréckbeweegt. Erënnert Dir Iech un dësem Diagramm?

Erstellt e Multiplayer Webspill am .io Genre
D'Spillupdate direkt lénks vun "Client Render Time" ass de Basisaktualiséierung.

Fir wat gëtt de Basisaktualiséierung benotzt? Firwat kënne mir Updates op d'Basis falen? Fir dëst ze verstoen, loosst eis endlech kucke mer op d'Ëmsetzung getCurrentState():

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

Mir behandelen dräi Fäll:

  1. base < 0 heescht datt et keng Updates gëtt bis déi aktuell Renderingzäit (kuckt d'Implementatioun hei uewen getBaseUpdate()). Dëst kann direkt am Ufank vum Spill geschéien wéinst Rendering Lag. An dësem Fall benotze mir dee rezentste Update kritt.
  2. base ass dee leschten Update dee mir hunn. Dëst ka geschéien wéinst der Netzlatenz oder enger schlechter Internetverbindung. Och an dësem Fall benotze mir déi lescht Update déi mir hunn.
  3. Mir hunn en Update souwuel virun an no der aktueller Renderzäit, sou datt mir kënnen interpoléieren!

Alles wat eriwwer ass state.js ass eng Implementatioun vu linearer Interpolatioun déi einfach (awer langweileg) Mathematik ass. Wann Dir et selwer wëllt entdecken, dann oppen state.js op Github.

Deel 2. Backend Server

An dësem Deel wäerte mir den Node.js Backend kucken, deen eis kontrolléiert Beispill vun engem .io Spill.

1. Server Entrée Punkt

Fir de Webserver ze verwalten wäerte mir e populäre Webframework fir Node.js genannt benotzen Express. Et gëtt vun eisem Server Entrée Punkt Fichier konfiguréiert src/server/server.js:

server.js, Deel 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}`);

Denkt drun datt mir am éischten Deel iwwer Webpack diskutéiert hunn? Dëst ass wou mir eis Webpack Konfiguratiounen benotzen. Mir wäerten se op zwou Weeër applizéieren:

  • Ze benotzen webpack-dev-middleware fir eis Entwécklungspäck automatesch opzebauen, oder
  • Statesch Transfert en Dossier dist/, an deem Webpack eis Dateien no der Produktioun Build schreift.

Aner wichteg Aufgab server.js besteet aus der Ariichten vum Server socket.iodeen einfach mam Express Server verbënnt:

server.js, Deel 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);
});

Nodeems Dir eng Socket.io Verbindung mam Server erfollegräich gegrënnt huet, konfiguréiere mir Eventhandler fir den neie Socket. Event Handler veraarbecht Messagen, déi vu Cliente kréien, andeems se op en Singleton Objet delegéieren game:

server.js, Deel 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);
}

Mir kreéieren en .io Spill, also brauche mir nëmmen eng Kopie Game ("Spill") - all Spiller spillen an der selwechter Arena! An der nächster Sektioun wäerte mir gesinn wéi dës Klass funktionnéiert Game.

2. Spill Serveren

Klass Game enthält déi wichtegst Server-Säit Logik. Et huet zwou Haaptaufgaben: Spiller Gestioun и Spill Simulatioun.

Loosst eis mat der éischter Aufgab ufänken - d'Gestioun vun de Spiller.

game.js, Deel 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);
    }
  }

  // ...
}

An dësem Spill wäerte mir Spiller no Feld identifizéieren id hir Socket socket.io (wann Dir duercherneen sidd, da gitt zréck op server.js). Socket.io selwer gëtt all Socket eng eenzegaarteg id, also brauche mer eis keng Suergen doriwwer ze maachen. Ech wäert him ruffen Spiller ID.

Mat deem vergiessen, loosst eis d'Instanzvariablen an der Klass ënnersichen Game:

  • sockets ass en Objet deen d'Spiller ID un de Socket bindt, dee mam Spiller assoziéiert ass. Et erlaabt eis Zougang zu Sockets duerch hir Spiller IDen iwwer Zäit.
  • players ass en Objet deen d'Spiller ID un de Code> Spiller Objet bindt

bullets ass eng Rei vun Objeten Bullet, net eng spezifesch Uerdnung hunn.
lastUpdateTime - Dëst ass den Zäitstempel vum leschte Spillupdate. Mir kucke wéi et geschwënn benotzt gëtt.
shouldSendUpdate ass eng Hëllefsvariabel. Mir wäerten och seng Notzung geschwënn gesinn.
Methoden addPlayer(), removePlayer() и handleInput() net néideg ze erklären, si sinn benotzt an server.js. Wann Dir eng Erfrëschung braucht, gitt e bësse méi héich zréck.

Déi lescht Zeil constructor() fänkt un update Zyklus Spiller (mat enger Frequenz vun 60 Updates / s):

game.js, Deel 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;
    }
  }

  // ...
}

Methode update() enthält wahrscheinlech de wichtegsten Deel vun der Server-Säit Logik. Loosst eis alles opzielen wat et mécht an Uerdnung:

  1. Berechent wéi eng Auer et ass dt et ass zanter dem leschte update().
  2. Erfrëscht all Projektil an zerstéiert se wann néideg. Mir wäerte spéider d'Ëmsetzung vun dëser Funktionalitéit gesinn. Fir elo geet et duer fir dat ze wëssen bullet.update() geet zréck true, wann de Projektil muss zerstéiert ginn (hien ass ausserhalb vun der Arena gaangen).
  3. Aktualiséiert all Spiller a kreéiert e Projektil wann néideg. Mir wäerten dës Ëmsetzung och spéider gesinn - player.update() kann en Objet zréckginn Bullet.
  4. Schecken fir Kollisiounen tëscht projectiles a Spiller benotzt applyCollisions(), déi eng Rei vu Projektilen zréckginn, déi Spiller schloen. Fir all zréckkomm Projektil erhéijen mir de Score vum Spiller deen et geschoss huet (benotzt player.onDealtDamage()), an dann de Projektil aus der Array erofhuelen bullets.
  5. Notifizéiert an zerstéiert all ëmbruecht Spiller.
  6. Schéckt e Spillupdate fir all Spiller all Sekonn Zäiten wann genannt update(). D'Hëllefsvariabel hei uewen erwähnt hëlleft eis dëst ze verfolgen shouldSendUpdate. Wéi update() genannt 60 Mol / s, mir schécken Spill Aktualiséierungen 30 Mol / s. Also, Auer Frequenz Server ass 30 Auer Zyklen / s (mir geschwat iwwer d'Auer Frequenz am éischten Deel).

Firwat nëmmen Spillupdates schécken duerch Zäit ? Kanal ze retten. 30 Spillupdates pro Sekonn ass vill!

Firwat dann net einfach uruffen? update() 30 Mol pro Sekonn? Fir d'Spill Simulatioun ze verbesseren. Wat méi dacks gëtt et genannt update(), wat méi genee d'Spillsimulatioun wäert sinn. Awer net zevill duerch d'Zuel vun den Erausfuerderunge matgedroen update(), well dëst eng computationally deier Aufgab ass - 60 pro Sekonn ass ganz genuch.

De Rescht vun der Klass Game besteet aus Hëllefsmethoden benotzt an update():

game.js, Deel 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() Et ass zimmlech einfach - et sortéiert Spiller no Score, hëlt déi Top fënnef, a gitt de Benotzernumm a Score fir all zréck.

createUpdate() benotzt an update() fir Spillupdates ze kreéieren déi un d'Spiller verdeelt ginn. Seng Haaptaufgab ass Methoden ze ruffen serializeForUpdate(), fir Klassen ëmgesat Player и Bullet. Bedenkt datt et nëmmen Daten un all Spiller iwwerdréit noosten Spiller a Projektilen - et ass net néideg Informatiounen iwwer Spillobjekter wäit vum Spiller ze vermëttelen!

3. Spill Objete op de Server

An eisem Spill, Projektilen a Spiller sinn eigentlech ganz ähnlech: si sinn abstrakt Ronn Plënneren Spill Objete. Fir vun dëser Ähnlechkeet tëscht Spiller a Projektilen ze profitéieren, loosst eis ufänken mat enger Basisklass ëmzesetzen 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,
    };
  }
}

Hei ass näischt komplizéiert lass. Dës Klass wäert e gudde Startpunkt fir Expansioun sinn. Loosst eis kucken wéi d'Klass Bullet benotzt 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;
  }
}

Ëmsetzung Bullet ganz kuerz! Mir hunn derbäigesat Object nëmmen déi folgend Extensiounen:

  • Benotzt de Package kuerz fir zoufälleg Generatioun id projektil.
  • Dobäizemaachen engem Feld parentID, sou datt Dir de Spiller verfollege kënnt, deen dëse Projektil erstallt huet.
  • Dobäizemaachen de Retour Wäert op update()wat gläich ass true, Wann de Projektil ausserhalb vun der Arena ass (erënnert Iech drun datt mir an der leschter Sektioun iwwer dëst geschwat hunn?).

Loosst eis op 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,
    };
  }
}

D'Spiller si méi komplex wéi Projektilen, sou datt dës Klass e puer méi Felder sollt späicheren. Seng Method update() mécht méi Aarbecht, virun allem de nei geschafen Projektil zréckzebréngen, wann et keng méi ass fireCooldown (Erënnert Iech drun datt mir an der viregter Rubrik iwwer dëst geschwat hunn?). Et verlängert och d'Method serializeForUpdate(), well mir mussen zousätzlech Felder fir de Spiller am Spill update.

Disponibilitéit vun enger Basis Klass Object - e wichtege Schrëtt Code Widderhuelung ze vermeiden. Zum Beispill, ouni Klass Object all Spill Objet muss déi selwecht Ëmsetzung hunn distanceTo(), a kopéieren-paste all dës Implementatiounen iwwer verschidde Dateien wier en Albtraum. Dëst gëtt besonnesch wichteg fir grouss Projeten, wann d'Zuel vun erweidert Object Klassen wuessen.

4. Kollisioun erkennen

Déi eenzeg Saach fir eis ze maachen ass ze erkennen wann d'Projektiler d'Spiller schloen! Erënneren dëse Code Snippet vun der Method update() an der Klass Game:

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

    // ...
  }
}

Mir mussen d'Method ëmsetzen applyCollisions(), déi all Projektilen zréckginn déi Spiller getraff hunn. Glécklecherweis ass dëst net sou schwéier ze maachen well

  • All kollidéierend Objete si Kreeser, an dëst ass déi einfachst Form fir Kollisiounserkennung ëmzesetzen.
  • Mir hu schonn eng Method distanceTo(), déi mir an der Klass an der viregter Rubrik ëmgesat hunn Object.

Dëst ass wéi eis Implementatioun vun der Kollisiounserkennung ausgesäit:

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

Dës einfach Kollisioun Detektioun baséiert op der Tatsaach, datt zwee Kreesser kollidéieren wann d'Distanz tëscht hiren Zentren manner ass wéi d'Zomm vun hiren Radien. Hei ass e Fall wou d'Distanz tëscht den Zentren vun zwee Krees genau d'selwecht ass wéi d'Zomm vun hiren Radien:

Erstellt e Multiplayer Webspill am .io Genre
Hei musst Dir op e puer méi Aspekter oppassen:

  • De Projektil däerf de Spiller net schloen, deen et erstallt huet. Dëst kann erreecht ginn andeems Dir vergläicht bullet.parentID с player.id.
  • De Projektil sollt nëmmen eemol schloen am extreme Fall vu méi Spiller zur selwechter Zäit ze schloen. Mir léisen dëse Problem mam Bedreiwer break: Wann e Spiller, dee mat engem Projektil kollidéiert ass, fonnt gëtt, stoppen mir d'Sich a fuere weider op den nächste Projektil.

The End

Dat ass alles! Mir hunn alles ofgedeckt wat Dir wësse musst fir en .io Webspill ze kreéieren. Wat ass nächst? Baut Äert eegent .io Spill!

All Beispill Code ass Open Source a gepost op Github.

Source: will.com

Setzt e Commentaire