Moninpelin .io-verkkopelin luominen

Moninpelin .io-verkkopelin luominen
Julkaistu vuonna 2015 Agar.io siitä tuli uuden genren esi-ihminen pelit .iojonka suosio on kasvanut sen jälkeen. Olen itse kokenut .io-pelien suosion kasvun: olen kokenut viimeisen kolmen vuoden aikana loi ja myi kaksi tämän genren peliä..

Jos et ole koskaan kuullut näistä peleistä ennen, nämä ovat ilmaisia ​​moninpelejä, joita on helppo pelata (tiliä ei vaadita). He kohtaavat yleensä useita vastustajia samalla areenalla. Muita kuuluisia .io-pelejä: Slither.io и Diep.io.

Tässä viestissä tutkimme kuinka luo .io-peli tyhjästä. Tähän riittää vain Javascriptin tuntemus: sinun on ymmärrettävä esimerkiksi syntaksi ES6, avainsana this и Lupauksia. Vaikka tietosi Javascriptistä ei olisi täydellinen, voit silti ymmärtää suurimman osan viestistä.

Esimerkki .io-pelistä

Oppimisapua varten viittaamme Esimerkki .io-pelistä. Yritä pelata sitä!

Moninpelin .io-verkkopelin luominen
Peli on melko yksinkertainen: ohjaat laivaa areenalla, jossa on muita pelaajia. Aluksesi ampuu automaattisesti ammuksia ja yrität osua muihin pelaajiin välttäen samalla heidän ammustaan.

1. Lyhyt katsaus / hankkeen rakenne

Suositella lataa lähdekoodi esimerkkipeli, jotta voit seurata minua.

Esimerkki käyttää seuraavaa:

  • Ilmaista on suosituin Node.js-verkkokehys, joka hallitsee pelin verkkopalvelinta.
  • socket.io - websocket-kirjasto tiedonvaihtoon selaimen ja palvelimen välillä.
  • webpack - moduulin johtaja. Voit lukea Webpackin käytön syistä. täällä.

Projektin hakemistorakenne näyttää tältä:

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

julkinen/

Kaikki kansiossa public/ palvelin lähettää staattisesti. SISÄÄN public/assets/ sisältää projektissamme käyttämiä kuvia.

src /

Kaikki lähdekoodi on kansiossa src/. Otsikot client/ и server/ puhuvat puolestaan ​​ja shared/ sisältää vakiotiedoston, jonka sekä asiakas että palvelin tuovat.

2. Kokoonpanot/projektin asetukset

Kuten edellä mainittiin, käytämme moduulinhallintaa projektin rakentamiseen. webpack. Katsotaanpa Webpack-kokoonpanoamme:

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

Tärkeimmät rivit tässä ovat:

  • src/client/index.js on Javascript (JS) -asiakkaan aloituspiste. Webpack alkaa tästä ja etsii rekursiivisesti muita tuotuja tiedostoja.
  • Webpack-koontiversiomme lähtö JS sijaitsee hakemistossa dist/. Kutsun tätä tiedostoa meidän js-paketti.
  • Käytämme Baabelja erityisesti kokoonpano @babel/preset-env JS-koodimme siirtämiseen vanhemmille selaimille.
  • Käytämme laajennusta purkaaksemme kaikki JS-tiedostojen viittaamat CSS-tiedostot ja yhdistämme ne yhteen paikkaan. Kutsun häntä meidän css-paketti.

Olet ehkä huomannut outoja pakettien tiedostonimiä '[name].[contenthash].ext'. Ne sisältävät tiedostonimien vaihdot Verkkopakkaus: [name] korvataan syöttöpisteen nimellä (tässä tapauksessa tämä game) ja [contenthash] korvataan tiedoston sisällön hashilla. Teemme sen optimoi projekti hajautusta varten - Voit käskeä selaimia tallentamaan JS-pakettimme välimuistiin rajoituksetta, koska jos paketti muuttuu, myös sen tiedostonimi muuttuu (muutoksia contenthash). Lopputuloksena on näkymätiedoston nimi game.dbeee76e91a97d0c7207.js.

tiedosto webpack.common.js on perusmääritystiedosto, jonka tuomme kehitys- ja valmiisiin projektikokoonpanoihin. Tässä on esimerkki kehityskokoonpanosta:

webpack.dev.js

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

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

Tehokkuuden vuoksi käytämme kehitysprosessissa webpack.dev.js, ja vaihtaa kohtaan webpack.prod.jsoptimoida pakkauskoot tuotannossa käyttöönoton yhteydessä.

Paikallinen asetus

Suosittelen projektin asentamista paikalliselle koneelle, jotta voit seurata tässä viestissä lueteltuja vaiheita. Asennus on yksinkertainen: ensin järjestelmän on oltava asennettuna Solmu и NPM. Seuraavaksi sinun on tehtävä

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

ja olet valmis lähtemään! Käynnistä kehityspalvelin vain suorittamalla

$ npm run develop

ja mene verkkoselaimeen localhost: 3000. Kehityspalvelin rakentaa automaattisesti JS- ja CSS-paketit uudelleen koodin muuttuessa - päivitä sivu nähdäksesi kaikki muutokset!

3. Asiakkaan sisäänpääsypisteet

Mennään itse pelin koodiin. Ensin tarvitsemme sivun index.html, kun vierailet sivustolla, selain lataa sen ensin. Sivustamme tulee melko yksinkertainen:

index.html

Esimerkki .io-pelistä  PELATA

Tätä koodiesimerkkiä on yksinkertaistettu hieman selvyyden vuoksi, ja teen samoin monien muiden viestiesimerkkien kanssa. Koko koodi on aina nähtävissä osoitteessa Github.

Meillä on:

  • HTML5-kanvaselementti (<canvas>), jota käytämme pelin renderöimiseen.
  • <link> lisätäksesi CSS-pakettimme.
  • <script> lisätäksesi Javascript-pakettimme.
  • Päävalikko käyttäjätunnuksella <input> ja PLAY-painike (<button>).

Kotisivun lataamisen jälkeen selain alkaa suorittaa Javascript-koodia aloituspisteen JS-tiedostosta: 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);
  };
});

Tämä saattaa kuulostaa monimutkaiselta, mutta tässä ei tapahdu paljon:

  1. Tuodaan useita muita JS-tiedostoja.
  2. CSS-tuonti (jotta Webpack tietää sisällyttävänsä ne CSS-pakettiimme).
  3. Запуск connect() muodostaaksesi yhteyden palvelimeen ja suorittaaksesi sen downloadAssets() ladataksesi pelin hahmontamiseen tarvittavia kuvia.
  4. Vaiheen 3 päätyttyä päävalikko tulee näkyviin (playMenu).
  5. Ohjaimen asettaminen "PLAY"-painikkeen painamista varten. Kun painiketta painetaan, koodi alustaa pelin ja kertoo palvelimelle, että olemme valmiita pelaamaan.

Asiakas-palvelinlogiikkamme tärkein "liha" on tiedostoissa, jotka tiedosto tuodaan index.js. Nyt tarkastelemme niitä kaikkia järjestyksessä.

4. Asiakastietojen vaihto

Tässä pelissä käytämme tunnettua kirjastoa kommunikoidaksemme palvelimen kanssa socket.io. Socket.io:lla on alkuperäinen tuki WebSockets, jotka sopivat hyvin kaksisuuntaiseen viestintään: voimme lähettää viestejä palvelimelle и palvelin voi lähettää meille viestejä samalla yhteydellä.

Meillä on yksi tiedosto src/client/networking.jskuka huolehtii kaikkien viestintä palvelimen kanssa:

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

Tätä koodia on myös hieman lyhennetty selvyyden vuoksi.

Tässä tiedostossa on kolme päätoimintoa:

  • Yritämme muodostaa yhteyttä palvelimeen. connectedPromise sallittu vain, kun olemme muodostaneet yhteyden.
  • Jos yhteys onnistuu, rekisteröimme takaisinsoittotoiminnot (processGameUpdate() и onGameOver()) viesteille, joita voimme vastaanottaa palvelimelta.
  • Viemme play() и updateDirection()jotta muut tiedostot voivat käyttää niitä.

5. Asiakasrenderöinti

On aika näyttää kuva näytöllä!

…mutta ennen kuin voimme tehdä sen, meidän on ladattava kaikki tähän tarvittavat kuvat (resurssit). Kirjoitetaan resurssienhallinta:

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

Resurssienhallinta ei ole niin vaikea toteuttaa! Pääideana on säilyttää esine assets, joka sitoo tiedostonimen avaimen objektin arvoon Image. Kun resurssi ladataan, tallennamme sen objektiin assets nopeaa käyttöä varten tulevaisuudessa. Milloin kukin yksittäinen resurssi saa ladata (eli kaikki resurssit), sallimme downloadPromise.

Kun olet ladannut resurssit, voit aloittaa renderoinnin. Kuten aiemmin todettiin, käytämme web-sivulle piirtämiseen HTML5 -kangas (<canvas>). Pelimme on melko yksinkertainen, joten meidän tarvitsee vain piirtää seuraavat:

  1. tausta
  2. Pelaajalaiva
  3. Muut pelaajat pelissä
  4. ammukset

Tässä ovat tärkeät katkelmat src/client/render.js, jotka näyttävät täsmälleen neljä yllä lueteltua kohdetta:

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

Tätä koodia on myös lyhennetty selvyyden vuoksi.

render() on tämän tiedoston päätoiminto. startRendering() и stopRendering() ohjaa renderöintisilmukan aktivointia nopeudella 60 FPS.

Yksittäisten renderöintiaputoimintojen konkreettiset toteutukset (esim. renderBullet()) eivät ole niin tärkeitä, mutta tässä on yksi yksinkertainen esimerkki:

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

Huomaa, että käytämme menetelmää getAsset(), joka nähtiin aiemmin vuonna asset.js!

Jos olet kiinnostunut oppimaan muista hahmontamisapuohjelmista, lue loput. src/client/render.js.

6. Asiakkaan syöttö

On aika tehdä peli pelattavissa! Ohjausjärjestelmä on hyvin yksinkertainen: muuttaaksesi liikesuuntaa, voit käyttää hiirtä (tietokoneessa) tai koskettaa näyttöä (mobiililaitteessa). Tämän toteuttamiseksi rekisteröidymme Tapahtumakuuntelijat Mouse and Touch -tapahtumiin.
Hoitaa kaiken tämän 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() ovat tapahtumakuuntelijoita, jotka soittavat updateDirection() (alkaen networking.js), kun syöttötapahtuma tapahtuu (esimerkiksi kun hiirtä liikutetaan). updateDirection() hoitaa viestinnän palvelimen kanssa, joka käsittelee syöttötapahtuman ja päivittää pelin tilan sen mukaisesti.

7. Asiakkaan tila

Tämä osio on vaikein postauksen ensimmäisessä osassa. Älä lannistu, jos et ymmärrä sitä ensimmäisellä kerralla! Voit jopa ohittaa sen ja palata siihen myöhemmin.

Viimeinen pala palapeliä, joka tarvitaan asiakas-/palvelinkoodin viimeistelyyn olivat. Muistatko koodinpätkän Client Rendering -osiosta?

render.js

import { getCurrentState } from './state';

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

  // Do the rendering
  // ...
}

getCurrentState() pitäisi pystyä kertomaan meille asiakkaan pelin nykytilasta milloin tahansa palvelimelta saatujen päivitysten perusteella. Tässä on esimerkki pelipäivityksestä, jonka palvelin voi lähettää:

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

Jokainen pelipäivitys sisältää viisi identtistä kenttää:

  • t: Palvelimen aikaleima, joka osoittaa, milloin tämä päivitys luotiin.
  • me: Tietoja tämän päivityksen vastaanottavasta soittimesta.
  • muut: Joukko tietoja muista samaan peliin osallistuvista pelaajista.
  • luoteja: joukko tietoa pelin ammuksista.
  • leaderboard: Nykyiset tulostaulukon tiedot. Tässä viestissä emme ota niitä huomioon.

7.1 Naiivi asiakastila

Naiivi toteutus getCurrentState() voi suoraan palauttaa vain viimeksi vastaanotetun pelipäivityksen tiedot.

naive-state.js

let lastGameUpdate = null;

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

export function getCurrentState() {
  return lastGameUpdate;
}

Hienoa ja selkeää! Mutta jos se vain olisi niin yksinkertaista. Yksi syistä, miksi tämä toteutus on ongelmallinen: se rajoittaa renderöinnin kehysnopeuden palvelimen kellotaajuuteen.

Ruudunpäivitysnopeus: kehysten määrä (eli puhelut render()) sekunnissa tai FPS. Pelit pyrkivät yleensä saavuttamaan vähintään 60 FPS.

Tick ​​Rate: Taajuus, jolla palvelin lähettää pelipäivityksiä asiakkaille. Se on usein pienempi kuin kuvanopeus. Pelissämme palvelin toimii 30 syklin taajuudella sekunnissa.

Jos teemme vain pelin viimeisimmän päivityksen, FPS ei käytännössä koskaan mene yli 30:een, koska emme saa koskaan yli 30 päivitystä sekunnissa palvelimelta. Vaikka soitamme render() 60 kertaa sekunnissa, sitten puolet näistä puheluista piirtää saman asian uudelleen, käytännössä tekemättä mitään. Toinen ongelma naiivissa toteutuksessa on se alttiita viivästyksiin. Ihanteellisella Internet-nopeudella asiakas saa pelipäivityksen täsmälleen 33 ms:n välein (30 sekunnissa):

Moninpelin .io-verkkopelin luominen
Valitettavasti mikään ei ole täydellistä. Realistisempi kuva olisi:
Moninpelin .io-verkkopelin luominen
Naiivi toteutus on käytännössä pahin tapaus latenssin suhteen. Jos pelipäivitys vastaanotetaan 50 ms viiveellä, niin asiakaskojuja ylimääräinen 50 ms, koska se näyttää edelleen pelin tilan edellisestä päivityksestä. Voit kuvitella kuinka epämiellyttävää tämä on pelaajalle: mielivaltainen jarrutus saa pelin tuntumaan nykivältä ja epävakaalta.

7.2 Parannettu asiakastila

Teemme joitain parannuksia naiiviin toteutukseen. Ensin käytämme renderöinnin viive 100 ms:n ajan. Tämä tarkoittaa, että asiakkaan "nykyinen" tila on aina 100 ms jäljessä palvelimella olevan pelin tilasta. Esimerkiksi jos palvelimen aika on 150, asiakas näyttää tilan, jossa palvelin oli tuolloin 50:

Moninpelin .io-verkkopelin luominen
Tämä antaa meille 100 ms puskurin selviytyäksemme arvaamattomista pelipäivitysajoista:

Moninpelin .io-verkkopelin luominen
Palkka tästä on pysyvä syöttöviive 100 ms:n ajan. Tämä on pieni uhraus sujuvan pelin kannalta - useimmat pelaajat (etenkin satunnaiset pelaajat) eivät edes huomaa tätä viivettä. Ihmisten on paljon helpompaa sopeutua jatkuvaan 100 ms:n latenssiin kuin leikkiä arvaamattomalla latenssilla.

Voimme myös käyttää toista tekniikkaa nimeltä asiakaspuolen ennuste, joka tekee hyvää työtä havaittujen latenssien vähentämisessä, mutta sitä ei käsitellä tässä viestissä.

Toinen käyttämämme parannus on lineaarinen interpolaatio. Renderöintiviiveen vuoksi olemme yleensä vähintään yhden päivityksen edellä asiakasohjelmassa nykyistä aikaa. Kun soitetaan getCurrentState(), voimme toteuttaa lineaarinen interpolaatio pelipäivitysten välillä juuri ennen ja jälkeen asiakkaan nykyisen ajan:

Moninpelin .io-verkkopelin luominen
Tämä ratkaisee kuvataajuusongelman: voimme nyt renderöidä ainutlaatuisia kehyksiä haluamallamme kuvanopeudella!

7.3 Parannetun asiakastilan käyttöönotto

Toteutusesimerkki sisään src/client/state.js käyttää sekä renderöintiviivettä että lineaarista interpolaatiota, mutta ei pitkään. Jaetaan koodi kahteen osaan. Tässä on ensimmäinen:

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

Ensimmäinen askel on selvittää mitä currentServerTime(). Kuten näimme aiemmin, jokainen pelipäivitys sisältää palvelimen aikaleiman. Haluamme käyttää renderöintiviivettä kuvan renderöimiseen 100 ms palvelimen takana, mutta emme koskaan tiedä nykyistä aikaa palvelimella, koska emme voi tietää, kuinka kauan päivitysten saapuminen meille kesti. Internet on arvaamaton ja sen nopeus voi vaihdella suuresti!

Tämän ongelman kiertämiseksi voimme käyttää järkevää likiarvoa: me olettaa, että ensimmäinen päivitys saapui heti. Jos tämä olisi totta, tietäisimme palvelinajan juuri tällä hetkellä! Tallennamme palvelimen aikaleiman sisään firstServerTimestamp ja pidä meidän paikallinen (asiakkaan) aikaleima samalla hetkellä gameStart.

Hei odota. Eikö sen pitäisi olla palvelinaika = asiakasaika? Miksi teemme eron "palvelimen aikaleiman" ja "asiakkaan aikaleiman" välillä? Tämä on hieno kysymys! Osoittautuu, että ne eivät ole sama asia. Date.now() palauttaa eri aikaleimat asiakkaassa ja palvelimessa, ja se riippuu näiden koneiden paikallisista tekijöistä. Älä koskaan oleta, että aikaleimat ovat samat kaikissa koneissa.

Nyt ymmärrämme mitä tekee currentServerTime(): se palaa nykyisen renderöintiajan palvelimen aikaleima. Toisin sanoen tämä on palvelimen nykyinen aika (firstServerTimestamp <+ (Date.now() - gameStart)) miinus renderöintiviive (RENDER_DELAY).

Katsotaanpa nyt, kuinka käsittelemme pelipäivityksiä. Kun se vastaanotetaan päivityspalvelimelta, sitä kutsutaan processGameUpdate()ja tallennamme uuden päivityksen taulukkoon gameUpdates. Tämän jälkeen muistin käytön tarkistamiseksi poistamme kaikki vanhat päivitykset peruspäivityskoska emme tarvitse niitä enää.

Mikä on "peruspäivitys"? Tämä ensimmäinen päivitys, jonka löydämme siirtymällä taaksepäin palvelimen nykyisestä ajasta. Muistatko tämän kaavion?

Moninpelin .io-verkkopelin luominen
Pelipäivitys suoraan "Client Render Time" -kohdan vasemmalla puolella on peruspäivitys.

Mihin peruspäivitystä käytetään? Miksi voimme pudottaa päivitykset lähtötasolle? Selvitetään tämä vihdoinkin harkita toteutusta getCurrentState():

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

Käsittelemme kolme tapausta:

  1. base < 0 tarkoittaa, että päivityksiä ei ole ennen nykyistä renderöintiaikaa (katso yllä oleva toteutus getBaseUpdate()). Tämä voi tapahtua heti pelin alussa renderöintiviiveen vuoksi. Tässä tapauksessa käytämme viimeisintä vastaanotettua päivitystä.
  2. base on viimeisin päivitys. Tämä voi johtua verkkoviiveestä tai huonosta Internet-yhteydestä. Tässä tapauksessa käytämme myös uusinta päivitystä.
  3. Meillä on päivitys sekä ennen nykyistä renderöintiaikaa että sen jälkeen, joten voimme interpoloida!

Kaikki mitä on jäljellä state.js on lineaarisen interpoloinnin toteutus, joka on yksinkertainen (mutta tylsä) matematiikka. Jos haluat tutkia sitä itse, avaa state.js päälle Github.

Osa 2. Taustapalvelin

Tässä osassa tarkastellaan Node.js-taustaohjelmaa, joka hallitsee meidän Esimerkki .io-pelistä.

1. Palvelimen sisääntulopiste

Verkkopalvelimen hallintaan käytämme suosittua Node.js:n verkkokehystä nimeltä Ilmaista. Se konfiguroidaan palvelimemme aloituspistetiedostolla src/server/server.js:

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

Muista, että ensimmäisessä osassa keskustelimme Webpackista? Tässä käytämme Webpack-kokoonpanojamme. Käytämme niitä kahdella tavalla:

  • Käytä webpack-dev-middleware kehittämään kehityspakettejamme automaattisesti uudelleen tai
  • staattisesti siirrä kansio dist/, johon Webpack kirjoittaa tiedostomme tuotannon rakentamisen jälkeen.

Toinen tärkeä tehtävä server.js on perustaa palvelin socket.iojoka vain muodostaa yhteyden Express-palvelimeen:

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

Kun socket.io-yhteys on muodostettu onnistuneesti palvelimeen, määritimme tapahtumakäsittelijät uudelle socketille. Tapahtumakäsittelijät käsittelevät asiakkailta saatuja viestejä delegoimalla ne yksittäiselle objektille game:

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

Olemme luomassa .io-peliä, joten tarvitsemme vain yhden kopion Game ("Peli") - kaikki pelaajat pelaavat samalla areenalla! Seuraavassa osiossa näemme kuinka tämä luokka toimii. Game.

2. Pelipalvelimet

Luokka Game sisältää palvelinpuolen tärkeimmän logiikan. Sillä on kaksi päätehtävää: pelaajien hallinta и pelin simulointi.

Aloitetaan ensimmäisestä tehtävästä, pelaajien hallinnasta.

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

  // ...
}

Tässä pelissä tunnistamme pelaajat kentän perusteella id heidän socket.io-pistorasiaan (jos olet hämmentynyt, palaa kohtaan server.js). Socket.io itse määrittää jokaiselle pistokkeelle yksilöllisen idjoten meidän ei tarvitse huolehtia siitä. soitan hänelle Pelaajan tunnus.

Tätä silmällä pitäen tutkitaan luokan ilmentymämuuttujia Game:

  • sockets on objekti, joka sitoo pelaajatunnuksen soittimeen liittyvään liitäntään. Sen avulla voimme käyttää pistorasioita heidän pelaajatunnuksiensa perusteella jatkuvassa ajassa.
  • players on objekti, joka sitoo pelaajatunnuksen koodiin> Player object

bullets on joukko esineitä Bullet, jolla ei ole varmaa järjestystä.
lastUpdateTime on pelin viimeisimmän päivityksen aikaleima. Katsotaan pian, miten sitä käytetään.
shouldSendUpdate on apumuuttuja. Näemme myös sen käytön lähiaikoina.
menetelmät addPlayer(), removePlayer() и handleInput() ei tarvitse selittää, niitä käytetään server.js. Jos haluat virkistää muistiasi, palaa hieman ylemmäs.

Viimeinen rivi constructor() lanseeraukset päivityssykli pelit (päivitystiheydellä 60/s):

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

  // ...
}

menetelmä update() sisältää ehkä tärkeimmän osan palvelinpuolen logiikkaa. Tässä on mitä se tekee, järjestyksessä:

  1. Laskee kuinka kauan dt mennyt viimeisestä update().
  2. Päivittää jokaisen ammuksen ja tuhoaa ne tarvittaessa. Näemme tämän toiminnon toteutuksen myöhemmin. Toistaiseksi meille riittää, että tiedämme sen bullet.update() palaa truejos ammus pitäisi tuhota (hän astui ulos areenalta).
  3. Päivittää jokaisen pelaajan ja synnyttää tarvittaessa ammuksen. Näemme myös tämän toteutuksen myöhemmin - player.update() voi palauttaa esineen Bullet.
  4. Tarkistaa törmäyksiä ammusten ja pelaajien välillä applyCollisions(), joka palauttaa joukon ammuksia, jotka osuivat pelaajiin. Jokaista palautettua ammusta kohti lisäämme sen ampuneen pelaajan pisteitä (käyttäen player.onDealtDamage()) ja poista sitten ammus ryhmästä bullets.
  5. Ilmoittaa ja tuhoaa kaikki tapetut pelaajat.
  6. Lähettää pelipäivityksen kaikille pelaajille joka sekunti kertaa kun soitettiin update(). Tämä auttaa meitä seuraamaan edellä mainittua apumuuttujaa. shouldSendUpdate... Koska update() soitetaan 60 kertaa/s, lähetämme pelipäivityksiä 30 kertaa/s. Täten, kellotaajuus palvelimen kello on 30 kelloa/s (puhuimme kellotaajuudesta ensimmäisessä osassa).

Miksi lähettää vain pelipäivityksiä läpi ajan ? Kanavan tallentamiseen. 30 pelipäivitystä sekunnissa on paljon!

Miksei vain soita update() 30 kertaa sekunnissa? Pelisimuloinnin parantamiseksi. Mitä useammin kutsutaan update(), sitä tarkempi pelisimulaatio on. Mutta älä hurahdu liikaa haasteisiin. update(), koska tämä on laskennallisesti kallis tehtävä - 60 sekunnissa riittää.

Muu luokka Game koostuu vuonna käytetyistä apumenetelmistä update():

game.js osa 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() melko yksinkertainen - se lajittelee pelaajat tulosten mukaan, ottaa viisi parasta ja palauttaa kunkin käyttäjänimen ja pisteet.

createUpdate() käytetty update() luodaksesi pelipäivityksiä, jotka jaetaan pelaajille. Sen päätehtävä on kutsua menetelmiä serializeForUpdate()toteutettu luokille Player и Bullet. Huomaa, että se välittää tietoja vain jokaiselle pelaajalle Lähin pelaajat ja ammukset - ei tarvitse lähettää tietoja pelikohteista, jotka ovat kaukana pelaajasta!

3. Peliobjektit palvelimella

Pelissämme ammukset ja pelaajat ovat itse asiassa hyvin samankaltaisia: ne ovat abstrakteja, pyöreitä, liikkuvia peliesineitä. Hyödynnä tätä pelaajien ja ammusten välistä samankaltaisuutta ottamalla käyttöön perusluokka 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,
    };
  }
}

Tässä ei ole mitään monimutkaista meneillään. Tämä luokka on hyvä tukikohta laajennukselle. Katsotaan kuinka luokka Bullet käyttää 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 Hyvin lyhyt! Olemme lisänneet Object vain seuraavat laajennukset:

  • Paketin käyttö lyhyt satunnaista sukupolvea varten id ammus.
  • Kentän lisääminen parentIDjotta voit seurata pelaajaa, joka loi tämän ammuksen.
  • Palautusarvon lisääminen arvoon update(), joka on yhtä suuri kuin truejos ammus on areenan ulkopuolella (muistatko, että puhuimme tästä viime osiossa?).

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

Pelaajat ovat monimutkaisempia kuin ammukset, joten tähän luokkaan tulisi tallentaa muutama kenttää enemmän. Hänen menetelmänsä update() tekee paljon työtä, erityisesti palauttaa vasta luodun ammuksen, jos sitä ei ole jäljellä fireCooldown (muistatko, että puhuimme tästä edellisessä osiossa?). Se myös laajentaa menetelmää serializeForUpdate(), koska meidän on sisällytettävä pelaajalle lisäkenttiä pelipäivitykseen.

Perusluokka Object - tärkeä askel koodin toistumisen välttämiseksi. Esimerkiksi ei luokkaa Object jokaisella peliobjektilla on oltava sama toteutus distanceTo(), ja kaikkien näiden toteutusten kopioiminen useisiin tiedostoihin olisi painajainen. Tämä on erityisen tärkeää suurissa projekteissa.kun määrä laajenee Object luokat kasvavat.

4. Törmäyksen tunnistus

Ainoa asia, mikä meille jää, on tunnistaa, kun ammukset osuvat pelaajiin! Muista tämä koodinpätkä menetelmästä update() luokassa 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),
    );

    // ...
  }
}

Meidän on otettava menetelmä käyttöön applyCollisions(), joka palauttaa kaikki pelaajiin osuneet ammukset. Onneksi sen tekeminen ei ole niin vaikeaa, koska

  • Kaikki törmäävät esineet ovat ympyröitä, mikä on yksinkertaisin muoto törmäyksen havaitsemiseksi.
  • Meillä on jo menetelmä distanceTo(), jonka toteutimme luokan edellisessä osiossa Object.

Tältä näyttää törmäyksentunnistuksen toteutuksemme:

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

Tämä yksinkertainen törmäysten havaitseminen perustuu siihen tosiasiaan kaksi ympyrää törmäävät toisiinsa, jos niiden keskipisteiden välinen etäisyys on pienempi kuin niiden säteiden summa. Tässä on tapaus, jossa kahden ympyrän keskipisteiden välinen etäisyys on täsmälleen yhtä suuri kuin niiden säteiden summa:

Moninpelin .io-verkkopelin luominen
Tässä on vielä pari näkökohtaa, jotka on otettava huomioon:

  • Ammus ei saa osua pelaajaan, joka loi sen. Tämä voidaan saavuttaa vertaamalla bullet.parentID с player.id.
  • Ammus saa osua vain kerran siinä tapauksessa, että useat pelaajat törmäävät samanaikaisesti. Ratkaisemme tämän ongelman käyttämällä operaattoria break: heti kun ammukseen törmäävä pelaaja löydetään, lopetamme etsinnän ja siirrymme seuraavaan ammukseen.

Конец

Siinä kaikki! Olemme käsitelleet kaiken, mitä sinun tulee tietää .io-verkkopelin luomiseen. Mitä seuraavaksi? Rakenna oma .io-pelisi!

Kaikki esimerkkikoodi on avoimen lähdekoodin ja julkaistu Github.

Lähde: will.com

Lisää kommentti