Skep 'n Multiplayer .io-webspeletjie

Skep 'n Multiplayer .io-webspeletjie
In 2015 vrygestel Agar.io het die stamvader van 'n nuwe genre geword speletjies .iowat sedertdien in gewildheid gegroei het. Ek het persoonlik die toename in gewildheid van .io-speletjies ervaar: oor die afgelope drie jaar het ek twee speletjies van hierdie genre geskep en verkoop..

As jy nog nooit van hierdie speletjies gehoor het nie, is dit gratis multispeler-webspeletjies wat maklik is om te speel (geen rekening vereis nie). Hulle kom gewoonlik teë met baie opponerende spelers in dieselfde arena. Ander bekende .io-speletjies: Slither.io и Diep.io.

In hierdie pos sal ons ondersoek hoe skep 'n .io-speletjie van nuuts af. Hiervoor sal slegs kennis van Javascript genoeg wees: jy moet dinge soos sintaksis verstaan ES6, sleutelwoord this и Beloftes. Selfs as jou kennis van Javascript nie perfek is nie, kan jy steeds die meeste van die pos verstaan.

.io speletjie voorbeeld

Vir leerbystand sal ons verwys na .io speletjie voorbeeld. Probeer om dit te speel!

Skep 'n Multiplayer .io-webspeletjie
Die spel is redelik eenvoudig: jy beheer 'n skip in 'n arena waar daar ander spelers is. Jou skip skiet outomaties projektiele af en jy probeer ander spelers tref terwyl jy hul projektiele vermy.

1. Kort oorsig / struktuur van die projek

beveel laai bronkode af voorbeeld speletjie sodat jy my kan volg.

Die voorbeeld gebruik die volgende:

  • uit te druk is die gewildste Node.js-webraamwerk wat die speletjie se webbediener bestuur.
  • socket.io - 'n websocket-biblioteek vir die uitruil van data tussen 'n blaaier en 'n bediener.
  • webpack - module bestuurder. Jy kan lees oor hoekom om Webpack te gebruik. hier.

Hier is hoe die projekgidsstruktuur lyk:

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

publiek/

Alles in 'n gids public/ sal staties deur die bediener ingedien word. IN public/assets/ bevat beelde wat deur ons projek gebruik word.

src /

Alle bronkode is in die gids src/... Name client/ и server/ praat vir hulself en shared/ bevat 'n konstante-lêer wat deur beide die kliënt en die bediener ingevoer word.

2. Samestellings/projekinstellings

Soos hierbo genoem, gebruik ons ​​die modulebestuurder om die projek te bou. webpack. Kom ons kyk na ons Webpack-konfigurasie:

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

Die belangrikste lyne hier is:

  • src/client/index.js is die toegangspunt van die Javascript (JS) kliënt. Webpack sal van hier af begin en rekursief vir ander ingevoerde lêers soek.
  • Die uitvoer JS van ons Webpack-bou sal in die gids geleë wees dist/. Ek sal hierdie lêer ons noem js pakket.
  • Ons gebruik Babel, en veral die konfigurasie @babel/preset-env om ons JS-kode vir ouer blaaiers te transpileer.
  • Ons gebruik 'n inprop om al die CSS waarna die JS-lêers verwys, te onttrek en dit op een plek te kombineer. Ek sal hom ons noem css pakket.

Jy het dalk vreemde pakketlêername opgemerk '[name].[contenthash].ext'. Hulle bevat lêernaamvervangings Webpak: [name] sal vervang word met die naam van die invoerpunt (in ons geval, dit game), en [contenthash] sal vervang word met 'n hash van die lêer se inhoud. Ons doen dit om optimaliseer die projek vir hashing - jy kan blaaiers vertel om ons JS-pakkette onbepaald te kas, want as 'n pakket verander, dan verander sy lêernaam ook (veranderinge contenthash). Die finale resultaat sal die naam van die aansiglêer wees game.dbeee76e91a97d0c7207.js.

lêer webpack.common.js is die basis konfigurasie lêer wat ons invoer in die ontwikkeling en voltooide projek konfigurasies. Hier is 'n voorbeeld van ontwikkelingskonfigurasie:

webpack.dev.js

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

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

Vir doeltreffendheid, gebruik ons ​​in die ontwikkelingsproses webpack.dev.js, en skakel oor na webpack.prod.jsom pakketgroottes te optimaliseer wanneer dit na produksie ontplooi word.

Plaaslike omgewing

Ek beveel aan dat u die projek op 'n plaaslike masjien installeer sodat u die stappe in hierdie pos kan volg. Die opstelling is eenvoudig: eerstens moet die stelsel geïnstalleer het Knoop и NPM. Volgende moet jy doen

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

en jy is gereed om te gaan! Om die ontwikkelingsbediener te begin, hardloop net

$ npm run develop

en gaan na webblaaier localhost: 3000. Die ontwikkelingsbediener sal outomaties die JS- en CSS-pakkette herbou soos die kode verander - verfris net die bladsy om al die veranderinge te sien!

3. Kliënt Toegangspunte

Kom ons gaan na die spelkode self. Eerstens het ons 'n bladsy nodig index.html, wanneer die webwerf besoek word, sal die blaaier dit eerste laai. Ons bladsy sal redelik eenvoudig wees:

index.html

'n Voorbeeld .io-speletjie  SPEEL

Hierdie kodevoorbeeld is effens vereenvoudig vir duidelikheid, en ek sal dieselfde doen met baie van die ander posvoorbeelde. Die volledige kode kan altyd besigtig word by GitHub.

Ons het:

  • HTML5-doekelement (<canvas>) wat ons sal gebruik om die speletjie weer te gee.
  • <link> om ons CSS-pakket by te voeg.
  • <script> om ons Javascript-pakket by te voeg.
  • Hoofkieslys met gebruikersnaam <input> en die PLAY-knoppie (<button>).

Nadat die tuisblad gelaai is, sal die blaaier Javascript-kode begin uitvoer, vanaf die ingangspunt JS-lêer: 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);
  };
});

Dit klink dalk ingewikkeld, maar daar is nie veel wat hier gebeur nie:

  1. Voer verskeie ander JS-lêers in.
  2. CSS-invoer (sodat Webpack weet om dit by ons CSS-pakket in te sluit).
  3. bekendstelling connect() om 'n verbinding met die bediener te vestig en te hardloop downloadAssets() om beelde af te laai wat nodig is om die speletjie weer te gee.
  4. Na voltooiing van fase 3 die hoofkieslys word vertoon (playMenu).
  5. Stel die hanteerder in om die "PLAY"-knoppie te druk. Wanneer die knoppie gedruk word, initialiseer die kode die speletjie en vertel die bediener dat ons gereed is om te speel.

Die hoof "vleis" van ons kliënt-bediener logika is in daardie lêers wat deur die lêer ingevoer is index.js. Nou sal ons hulle almal in volgorde oorweeg.

4. Uitruil van kliëntedata

In hierdie speletjie gebruik ons ​​'n bekende biblioteek om met die bediener te kommunikeer socket.io. Socket.io het inheemse ondersteuning web voetstukke, wat goed geskik is vir tweerigtingkommunikasie: ons kan boodskappe na die bediener stuur и die bediener kan boodskappe aan ons stuur op dieselfde verbinding.

Ons sal een lêer hê src/client/networking.jswie sal sorg deur almal kommunikasie met die bediener:

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

Hierdie kode is ook effens verkort vir duidelikheid.

Daar is drie hoofhandelinge in hierdie lêer:

  • Ons probeer om aan die bediener te koppel. connectedPromise slegs toegelaat wanneer ons 'n verbinding tot stand gebring het.
  • As die verbinding suksesvol is, registreer ons terugbelfunksies (processGameUpdate() и onGameOver()) vir boodskappe wat ons van die bediener kan ontvang.
  • Ons voer uit play() и updateDirection()sodat ander lêers dit kan gebruik.

5. Kliëntlewering

Dit is tyd om die prentjie op die skerm te vertoon!

…maar voordat ons dit kan doen, moet ons al die beelde (hulpbronne) wat hiervoor benodig word, aflaai. Kom ons skryf 'n hulpbronbestuurder:

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

Hulpbronbestuur is nie so moeilik om te implementeer nie! Die hoofgedagte is om 'n voorwerp te stoor assets, wat die sleutel van die lêernaam aan die waarde van die voorwerp sal bind Image. Wanneer die hulpbron gelaai is, stoor ons dit in 'n voorwerp assets vir vinnige toegang in die toekoms. Wanneer sal elke individuele hulpbron toegelaat word om af te laai (dit is, alle hulpbronne), wat ons toelaat downloadPromise.

Nadat u die hulpbronne afgelaai het, kan u begin om weer te gee. Soos vroeër gesê, gebruik ons ​​om op 'n webblad te teken HTML5-doek (<canvas>). Ons spel is redelik eenvoudig, so ons hoef net die volgende te teken:

  1. agtergrond
  2. Spelerskip
  3. Ander spelers in die spel
  4. Skulpies

Hier is die belangrike brokkies src/client/render.js, wat presies die vier items hierbo weergee:

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

Hierdie kode is ook verkort vir duidelikheid.

render() is die hooffunksie van hierdie lêer. startRendering() и stopRendering() beheer die aktivering van die leweringlus by 60 FPS.

Konkrete implementering van individuele lewering helper funksies (bv. renderBullet()) is nie so belangrik nie, maar hier is een eenvoudige voorbeeld:

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

Let daarop dat ons die metode gebruik getAsset(), wat voorheen gesien is in asset.js!

As jy belangstel om oor ander leweringshelpers te leer, lees dan die res. src/client/render.js.

6. Kliënte-insette

Dit is tyd om 'n speletjie te maak speelbaar! Die beheerskema sal baie eenvoudig wees: om die bewegingsrigting te verander, kan jy die muis (op 'n rekenaar) gebruik of die skerm aanraak (op 'n mobiele toestel). Om dit te implementeer, sal ons registreer Luisteraars van die geleentheid vir Muis en Raak-geleenthede.
Sal sorg vir dit alles src/client/input.js:

invoer.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() is gebeurtenisluisteraars wat bel updateDirection() (van networking.js) wanneer 'n invoergebeurtenis plaasvind (byvoorbeeld wanneer die muis beweeg word). updateDirection() hanteer boodskappe met die bediener, wat die invoergebeurtenis hanteer en die speltoestand dienooreenkomstig bywerk.

7. Kliëntstatus

Hierdie afdeling is die moeilikste in die eerste deel van die pos. Moenie moedeloos wees as jy dit nie verstaan ​​die eerste keer dat jy dit lees nie! Jy kan dit selfs oorslaan en later daarna terugkom.

Die laaste stukkie van die legkaart wat nodig is om die kliënt/bedienerkode te voltooi, is was. Onthou jy die kodebrokkie van die Kliëntweergawe-afdeling?

lewer.js

import { getCurrentState } from './state';

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

  // Do the rendering
  // ...
}

getCurrentState() moet ons die huidige stand van die spel in die kliënt kan gee op enige tydstip gebaseer op opdaterings wat vanaf die bediener ontvang is. Hier is 'n voorbeeld van 'n speletjie-opdatering wat die bediener kan stuur:

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

Elke speletjie-opdatering bevat vyf identiese velde:

  • t: Bediener tydstempel wat aandui wanneer hierdie opdatering geskep is.
  • me: Inligting oor die speler wat hierdie opdatering ontvang.
  • ander: 'n Verskeidenheid inligting oor ander spelers wat aan dieselfde speletjie deelneem.
  • bullets: 'n verskeidenheid inligting oor projektiele in die spel.
  • leader: Huidige puntelysdata. In hierdie pos sal ons hulle nie oorweeg nie.

7.1 Naïewe kliëntstaat

Naïewe implementering getCurrentState() kan slegs die data van die mees onlangse speletjie-opdatering direk terugstuur.

naïef-staat.js

let lastGameUpdate = null;

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

export function getCurrentState() {
  return lastGameUpdate;
}

Mooi en duidelik! Maar as dit net so eenvoudig was. Een van die redes waarom hierdie implementering problematies is: dit beperk die leweringsraamtempo tot die bedienerkloktempo.

Raamtempo: aantal rame (d.w.s. oproepe render()) per sekonde, of FPS. Speletjies streef gewoonlik daarna om ten minste 60 FPS te behaal.

Merk koers: Die frekwensie waarteen die bediener speletjie-opdaterings aan kliënte stuur. Dit is dikwels laer as die raamkoers. In ons speletjie loop die bediener teen 'n frekwensie van 30 siklusse per sekonde.

As ons net die nuutste opdatering van die speletjie lewer, sal die FPS in wese nooit meer as 30 gaan nie, want ons kry nooit meer as 30 opdaterings per sekonde vanaf die bediener nie. Al bel ons render() 60 keer per sekonde, dan sal die helfte van hierdie oproepe net dieselfde ding oorteken, in wese niks doen nie. Nog 'n probleem met die naïewe implementering is dat dit vatbaar vir vertragings. Met 'n ideale internetspoed sal die kliënt presies elke 33ms (30 per sekonde) 'n speletjie-opdatering ontvang:

Skep 'n Multiplayer .io-webspeletjie
Ongelukkig is niks perfek nie. 'n Meer realistiese prentjie sou wees:
Skep 'n Multiplayer .io-webspeletjie
Die naïewe implementering is feitlik die ergste geval wanneer dit kom by latency. As 'n speletjie-opdatering ontvang word met 'n vertraging van 50 ms, dan kliënte stalletjies 'n ekstra 50ms omdat dit steeds die spelstatus van die vorige opdatering weergee. Jy kan jou voorstel hoe ongemaklik dit vir die speler is: arbitrêre rem sal die spel rukkerig en onstabiel laat voel.

7.2 Verbeterde kliënt toestand

Ons sal 'n paar verbeterings aan die naïewe implementering aanbring. Eerstens gebruik ons lewering vertraging vir 100 ms. Dit beteken dat die "huidige" toestand van die kliënt altyd 100 ms agter die toestand van die spel op die bediener sal wees. Byvoorbeeld, as die tyd op die bediener is 150, dan sal die kliënt die toestand weergee waarin die bediener op daardie stadium was 50:

Skep 'n Multiplayer .io-webspeletjie
Dit gee ons 'n buffer van 100 ms om onvoorspelbare speletjie-opdateringstye te oorleef:

Skep 'n Multiplayer .io-webspeletjie
Die uitbetaling hiervoor sal permanent wees insette vertraging vir 100 ms. Dit is 'n geringe opoffering vir gladde spel - die meeste spelers (veral gemaklike spelers) sal nie eers hierdie vertraging agterkom nie. Dit is baie makliker vir mense om aan te pas by 'n konstante 100ms latency as wat dit is om met 'n onvoorspelbare latency te speel.

Ons kan ook 'n ander tegniek gebruik genaamd kliënt-kant voorspelling, wat 'n goeie werk doen om waargenome latensie te verminder, maar sal nie in hierdie pos gedek word nie.

Nog 'n verbetering wat ons gebruik is lineêre interpolasie. Weens leweringsvertraging is ons gewoonlik ten minste een opdatering voor die huidige tyd in die kliënt. Wanneer geroep word getCurrentState(), kan ons uitvoer lineêre interpolasie tussen speletjie-opdaterings net voor en na die huidige tyd in die kliënt:

Skep 'n Multiplayer .io-webspeletjie
Dit los die raamtempo-kwessie op: ons kan nou unieke rame lewer teen enige raamtempo wat ons wil hê!

7.3 Implementering van verbeterde kliëntstaat

Implementeringsvoorbeeld in src/client/state.js gebruik beide weergavevertraging en lineêre interpolasie, maar nie vir lank nie. Kom ons breek die kode in twee dele. Hier is die eerste een:

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

Die eerste stap is om uit te vind wat currentServerTime(). Soos ons vroeër gesien het, bevat elke speletjie-opdatering 'n bedienertydstempel. Ons wil lewering latency gebruik om die beeld 100ms agter die bediener weer te gee, maar ons sal nooit die huidige tyd op die bediener weet nie, want ons kan nie weet hoe lank dit geneem het vir enige van die opdaterings om by ons uit te kom nie. Die internet is onvoorspelbaar en die spoed daarvan kan baie verskil!

Om hierdie probleem te omseil, kan ons 'n redelike benadering gebruik: ons maak asof die eerste opdatering onmiddellik opgedaag het. As dit waar was, sou ons die bedienertyd op hierdie spesifieke oomblik weet! Ons stoor die bediener se tydstempel in firstServerTimestamp en hou ons plaaslik (kliënt) tydstempel op dieselfde oomblik in gameStart.

Nee wag. Moet dit nie bedienertyd = kliënttyd wees nie? Waarom onderskei ons tussen "bedienertydstempel" en "kliënttydstempel"? Dit is 'n wonderlike vraag! Dit blyk dat hulle nie dieselfde ding is nie. Date.now() sal verskillende tydstempels in die kliënt en bediener terugstuur, en dit hang af van faktore wat plaaslik op hierdie masjiene is. Moet nooit aanvaar dat tydstempels op alle masjiene dieselfde sal wees nie.

Nou verstaan ​​ons wat dit doen currentServerTime(): dit keer terug die bediener tydstempel van die huidige lewering tyd. Met ander woorde, dit is die bediener se huidige tyd (firstServerTimestamp <+ (Date.now() - gameStart)) minus lewering vertraging (RENDER_DELAY).

Kom ons kyk nou hoe ons speletjie-opdaterings hanteer. Wanneer dit van die opdateringsbediener ontvang word, word dit genoem processGameUpdate()en ons stoor die nuwe opdatering in 'n skikking gameUpdates. Dan, om die geheuegebruik na te gaan, verwyder ons al die ou opdaterings vantevore basis opdateringwant ons het hulle nie meer nodig nie.

Wat is 'n "basiese opdatering"? Hierdie die eerste opdatering vind ons deur agteruit te beweeg vanaf die bediener se huidige tyd. Onthou jy hierdie diagram?

Skep 'n Multiplayer .io-webspeletjie
Die speletjie-opdatering direk aan die linkerkant van "Client Render Time" is die basisopdatering.

Waarvoor word die basisopdatering gebruik? Waarom kan ons opdaterings na die basislyn laat val? Om dit uit te vind, kom ons uiteindelik die implementering oorweeg 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),
    };
  }
}

Ons hanteer drie sake:

  1. base < 0 beteken dat daar geen opdaterings is tot die huidige leweringstyd nie (sien implementering hierbo getBaseUpdate()). Dit kan reg aan die begin van die speletjie gebeur as gevolg van leweringsvertraging. In hierdie geval gebruik ons ​​die jongste opdatering wat ontvang is.
  2. base is die nuutste opdatering wat ons het. Dit kan wees as gevolg van netwerkvertraging of swak internetverbinding. In hierdie geval gebruik ons ​​ook die nuutste opdatering wat ons het.
  3. Ons het 'n opdatering beide voor en na die huidige leweringtyd, so ons kan interpoleer!

Al wat oorgebly het state.js is 'n implementering van lineêre interpolasie wat eenvoudige (maar vervelige) wiskunde is. As jy dit self wil verken, maak dan oop state.js op GitHub.

Deel 2. Backend-bediener

In hierdie deel, sal ons kyk na die Node.js backend wat ons beheer .io speletjie voorbeeld.

1. Bediener Ingangspunt

Om die webbediener te bestuur, sal ons 'n gewilde webraamwerk gebruik vir Node.js genoem uit te druk. Dit sal gekonfigureer word deur ons bedieneringangspuntlêer 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}`);

Onthou jy dat ons in die eerste deel Webpack bespreek het? Dit is waar ons ons Webpack-konfigurasies sal gebruik. Ons sal hulle op twee maniere gebruik:

  • maak gebruik van webpack-dev-middelware om outomaties ons ontwikkelingspakkette te herbou, of
  • staties oordra gids dist/, waarin Webpack ons ​​lêers sal skryf na die produksiebou.

Nog 'n belangrike taak server.js is om die bediener op te stel socket.iowat net aan die Express-bediener koppel:

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

Nadat ons 'n socket.io-verbinding met die bediener suksesvol tot stand gebring het, het ons gebeurtenishanteerders vir die nuwe sok opgestel. Gebeurtenishanteerders hanteer boodskappe wat van kliënte ontvang word deur te delegeer na 'n enkelvoudige voorwerp 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);
}

Ons skep 'n .io-speletjie, so ons benodig net een kopie Game ("Spel") - alle spelers speel in dieselfde arena! In die volgende afdeling sal ons sien hoe hierdie klas werk. Game.

2. Speletjiebedieners

Klas Game bevat die belangrikste logika aan die bedienerkant. Dit het twee hooftake: speler bestuur и spel simulasie.

Kom ons begin met die eerste taak, spelerbestuur.

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

  // ...
}

In hierdie speletjie sal ons die spelers per veld identifiseer id hul socket.io-sok (as jy deurmekaar raak, gaan dan terug na server.js). Socket.io ken elke sok self 'n unieke toe idso ons hoef nie daaroor bekommerd te wees nie. Ek sal hom bel Speler ID.

Met dit in gedagte, kom ons ondersoek instansieveranderlikes in 'n klas Game:

  • sockets is 'n voorwerp wat die speler-ID bind aan die sok wat met die speler geassosieer word. Dit stel ons in staat om toegang tot voetstukke te kry deur hul speler-ID's in 'n konstante tyd.
  • players is 'n voorwerp wat die speler-ID aan die kode>Spelervoorwerp bind

bullets is 'n reeks voorwerpe Bullet, wat geen definitiewe volgorde het nie.
lastUpdateTime is die tydstempel van die laaste keer dat die speletjie opgedateer is. Ons sal binnekort sien hoe dit gebruik word.
shouldSendUpdate is 'n hulpveranderlike. Ons sal ook binnekort die gebruik daarvan sien.
metodes addPlayer(), removePlayer() и handleInput() nie nodig om te verduidelik nie, hulle word gebruik in server.js. As jy jou geheue moet verfris, gaan 'n bietjie hoër terug.

Laaste reël constructor() begin opdateringsiklus speletjies (met 'n frekwensie van 60 opdaterings / 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;
    }
  }

  // ...
}

metode update() bevat miskien die belangrikste stuk logika aan die bedienerkant. Hier is wat dit doen, in volgorde:

  1. Bereken hoe lank dt verby sedert die laaste update().
  2. Verfris elke projektiel en vernietig dit indien nodig. Ons sal later die implementering van hierdie funksionaliteit sien. Vir nou is dit genoeg vir ons om dit te weet bullet.update() keer terug trueindien die projektiel vernietig moet word (hy het uit die arena gestap).
  3. Dateer elke speler op en skep 'n projektiel indien nodig. Ons sal ook later hierdie implementering sien − player.update() kan 'n voorwerp terugstuur Bullet.
  4. Kontroleer vir botsings tussen projektiele en spelers met applyCollisions(), wat 'n verskeidenheid projektiele terugstuur wat spelers tref. Vir elke projektiel wat teruggekeer word, verhoog ons die punte van die speler wat dit afgevuur het (met player.onDealtDamage()) en verwyder dan die projektiel uit die skikking bullets.
  5. Stel alle vermoorde spelers in kennis en vernietig.
  6. Stuur 'n speletjie-opdatering aan alle spelers elke sekonde tye wanneer geroep word update(). Dit help ons om tred te hou met die hulpveranderlike wat hierbo genoem is. shouldSendUpdate... Soos update() 60 keer/s genoem, stuur ons speletjie-opdaterings 30 keer/s. Dus, klok frekwensie bedienerklok is 30 klokke/s (ons het in die eerste deel oor klokkoerse gepraat).

Hoekom stuur net speletjie-opdaterings deur tyd ? Om kanaal te stoor. 30 speletjie-opdaterings per sekonde is baie!

Hoekom nie net bel nie update() 30 keer per sekonde? Om die spelsimulasie te verbeter. Die meer dikwels genoem update(), hoe meer akkuraat sal die spelsimulasie wees. Maar moenie te meegevoer raak met die aantal uitdagings nie. update(), want dit is 'n rekenkundige duur taak - 60 per sekonde is genoeg.

Die res van die klas Game bestaan ​​uit helpermetodes wat gebruik word in 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() redelik eenvoudig - dit sorteer die spelers volgens telling, neem die top vyf, en gee die gebruikersnaam en telling vir elkeen terug.

createUpdate() gebruik in update() om speletjie-opdaterings te skep wat aan spelers versprei word. Sy hooftaak is om metodes te noem serializeForUpdate()geïmplementeer vir klasse Player и Bullet. Let daarop dat dit slegs data aan elke speler oor gee naaste spelers en projektiele - dit is nie nodig om inligting oor spelvoorwerpe wat ver van die speler af is, oor te dra nie!

3. Spelvoorwerpe op die bediener

In ons spel is projektiele en spelers eintlik baie soortgelyk: hulle is abstrakte, ronde, beweegbare spelvoorwerpe. Om voordeel te trek uit hierdie ooreenkoms tussen spelers en projektiele, kom ons begin deur die basisklas te implementeer Object:

voorwerp.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,
    };
  }
}

Hier is niks ingewikkelds aan die gang nie. Hierdie klas sal 'n goeie ankerpunt vir die uitbreiding wees. Kom ons kyk hoe die klas Bullet gebruike 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;
  }
}

Implementering Bullet baie kort! Ons het bygevoeg Object slegs die volgende uitbreidings:

  • Met behulp van 'n pakket kortstondig vir willekeurige generasie id projektiel.
  • Voeg 'n veld by parentIDsodat jy die speler kan dophou wat hierdie projektiel geskep het.
  • Voeg 'n terugkeerwaarde by update(), wat gelyk is aan trueas die projektiel buite die arena is (onthou jy ons het hieroor in die laaste afdeling gepraat?).

Kom ons gaan voort na Player:

speler.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,
    };
  }
}

Spelers is meer kompleks as projektiele, so 'n paar meer velde moet in hierdie klas gestoor word. Sy metode update() doen baie werk, veral, gee die nuutgeskepte projektiel terug as daar niks oor is nie fireCooldown (Onthou ons dat ons in die vorige afdeling hieroor gepraat het?). Dit brei ook die metode uit serializeForUpdate(), want ons moet bykomende velde vir die speler in die spelopdatering insluit.

Om 'n basisklas te hê Object - 'n belangrike stap om herhaling van kode te vermy. Byvoorbeeld, geen klas nie Object elke speletjie-voorwerp moet dieselfde implementering hê distanceTo(), en kopieer-plak van al hierdie implementerings oor verskeie lêers sou 'n nagmerrie wees. Dit word veral belangrik vir groot projekte.wanneer die aantal uit te brei Object klasse groei.

4. Botsingsopsporing

Die enigste ding wat vir ons oorbly, is om te herken wanneer die projektiele die spelers tref! Onthou hierdie stukkie kode van die metode update() in die klas 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),
    );

    // ...
  }
}

Ons moet die metode implementeer applyCollisions(), wat alle projektiele wat spelers tref, terugstuur. Gelukkig is dit nie so moeilik om te doen nie, want

  • Alle botsende voorwerpe is sirkels, en dit is die eenvoudigste vorm om botsingsopsporing te implementeer.
  • Ons het reeds 'n metode distanceTo(), wat ons in die vorige afdeling in die klas geïmplementeer het Object.

Hier is hoe ons implementering van botsingsopsporing lyk:

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

Hierdie eenvoudige botsingsopsporing is gebaseer op die feit dat twee sirkels bots as die afstand tussen hul middelpunte kleiner is as die som van hul radiusse. Hier is die geval waar die afstand tussen die middelpunte van twee sirkels presies gelyk is aan die som van hul radiusse:

Skep 'n Multiplayer .io-webspeletjie
Daar is nog 'n paar aspekte om hier te oorweeg:

  • Die projektiel mag nie die speler tref wat dit geskep het nie. Dit kan bereik word deur te vergelyk bullet.parentID с player.id.
  • Die projektiel moet net een keer tref in die beperkende geval van verskeie spelers wat gelyktydig bots. Ons sal hierdie probleem oplos deur die operateur te gebruik break: sodra die speler wat met die projektiel bots gevind word, stop ons die soektog en beweeg aan na die volgende projektiel.

Die einde

Dis al! Ons het alles gedek wat jy moet weet om 'n .io-webspeletjie te skep. Wat is volgende? Bou jou eie .io-speletjie!

Alle voorbeeldkode is oopbron en geplaas op GitHub.

Bron: will.com

Voeg 'n opmerking