It meitsjen fan in Multiplayer .io Web Game

It meitsjen fan in Multiplayer .io Web Game
Utjûn yn 2015 Agar.io waard de foarfaar fan in nij sjenre spultsjes .iody't sûnt dy tiid yn populariteit groeid is. Ik haw persoanlik meimakke de opkomst yn populariteit fan .io games: oer de ôfrûne trije jier, ik haw makke en ferkocht twa spultsjes fan dit sjenre..

Yn gefal dat jo noch noait earder fan dizze spultsjes heard hawwe, binne dit fergese multiplayer-webspultsjes dy't maklik te spyljen binne (gjin akkount fereaske). Se meastal konfrontearre in protte tsjinstanner spilers yn deselde arena. Oare ferneamde .io-spultsjes: Slither.io и Diep.io.

Yn dizze post sille wy ûndersykje hoe meitsje in .io spultsje fanôf it begjin. Hjirfoar sil allinich kennis fan Javascript genôch wêze: jo moatte dingen lykas syntaksis begripe ES6, kaaiwurd this и Promises. Sels as jo kennis fan Javascript net perfekt is, kinne jo it measte fan 'e post noch begripe.

.io spultsje foarbyld

Foar learbystân sille wy ferwize nei .io spultsje foarbyld. Besykje it te spyljen!

It meitsjen fan in Multiplayer .io Web Game
It spultsje is frij simpel: jo behearskje in skip yn in arena dêr't der binne oare spilers. Jo skip sjit automatysk projektilen ôf en jo besykje oare spilers te slaan wylst se har projektilen foarkomme.

1. Koarte oersjoch / struktuer fan it projekt

Ik jou it oan download boarne koade foarbyld spultsje sadat jo kinne folgje my.

It foarbyld brûkt it folgjende:

  • Express is it populêrste Node.js-webramt dat de webserver fan it spultsje beheart.
  • socket.io - in websocket-bibleteek foar it útwikseljen fan gegevens tusken in browser en in server.
  • Webpack - module manager. Jo kinne lêze oer wêrom't jo Webpack brûke. hjir.

Hjir is hoe't de projektmapstruktuer derút sjocht:

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

iepenbier/

Alles yn in map public/ sil statysk yntsjinne wurde troch de tsjinner. YN public/assets/ befettet ôfbyldings brûkt troch ús projekt.

src /

Alle boarnekoade is yn 'e map src/... Nammen client/ и server/ sprekke foar harsels en shared/ befettet in konstante triem dat wurdt ymportearre troch sawol de client en de tsjinner.

2. Assemblies / projekt ynstellings

Lykas hjirboppe neamd, brûke wy de modulemanager om it projekt te bouwen. Webpack. Litte wy nei ús Webpack-konfiguraasje sjen:

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

De wichtichste rigels hjir binne:

  • src/client/index.js is it yngongspunt fan 'e Javascript (JS) kliïnt. Webpack sil hjirwei begjinne en rekursyf sykje nei oare ymporteare bestannen.
  • De útfier JS fan ús Webpack build sil lizze yn 'e map dist/. Ik sil neame dit bestân ús js pakket.
  • Wy brûke babel, en benammen de konfiguraasje @babel/preset-env om ús JS-koade te transpilearjen foar âldere browsers.
  • Wy brûke in plugin om alle CSS te ekstrahearjen ferwiisd troch de JS-bestannen en kombinearje se op ien plak. Ik sil him ús neame css pakket.

Jo hawwe miskien frjemde pakkettriemnammen opmurken '[name].[contenthash].ext'. Se befetsje triemnamme ferfangings webpack: [name] sil wurde ferfongen troch de namme fan it ynfierpunt (yn ús gefal, dit game), en [contenthash] sil ferfongen wurde troch in hash fan de ynhâld fan it bestân. Wy dogge it oan optimalisearje it projekt foar hashing - jo kinne browsers fertelle om ús JS-pakketten foar ûnbepaalde tiid te cache, om't as in pakket feroaret, dan feroaret de triemnamme ek (feroarings contenthash). It einresultaat sil de namme wêze fan it werjeftebestân game.dbeee76e91a97d0c7207.js.

file webpack.common.js is it basiskonfiguraasjetriem dat wy ymportearje yn 'e ûntwikkeling en ôfmakke projektkonfiguraasjes. Hjir is in foarbyld fan ûntwikkelingskonfiguraasje:

webpack.dev.js

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

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

Foar effisjinsje brûke wy yn it ûntwikkelingsproses webpack.dev.js, en skeakelt nei webpack.prod.jsom pakketgrutte te optimalisearjen by it ynsetten nei produksje.

Lokale ynstelling

Ik advisearje it projekt te ynstallearjen op in lokale masine, sadat jo de stappen kinne folgje yn dizze post. De opset is ienfâldich: earst moat it systeem ynstalleare hawwe node и NPM. Folgjende jo moatte dwaan

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

en do bist klear om te gean! Om de ûntwikkelingstsjinner te begjinnen, rinne gewoan

$ npm run develop

en gean nei webblêder localhost: 3000. De ûntwikkelingstsjinner sil de JS- en CSS-pakketten automatysk opnij opbouwe as de koade feroaret - ferfarskje gewoan de side om alle wizigingen te sjen!

3. Client Entry Punten

Lit ús gean del nei it spultsje koade sels. Earst moatte wy in side hawwe index.html, by it besykjen fan de side sil de browser it earst lade. Us side sil frij ienfâldich wêze:

index.html

In foarbyld .io spultsje  TOANIELSTIK

Dit koadefoarbyld is wat ferienfâldige foar dúdlikens, en ik sil itselde dwaan mei in protte fan 'e oare postfoarbylden. De folsleine koade kin altyd besjoen wurde op Github.

Wy hawwe:

  • HTML5 canvas elemint (<canvas>) dy't wy sille brûke om it spultsje te werjaan.
  • <link> om ús CSS-pakket ta te foegjen.
  • <script> om ús Javascript-pakket ta te foegjen.
  • Haadmenu mei brûkersnamme <input> en de PLAY knop (<button>).

Nei it laden fan de thússide sil de browser begjinne mei it útfieren fan Javascript-koade, begjinnend fan it yngongspunt JS-bestân: 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 klinkt miskien yngewikkeld, mar d'r bart hjir net folle:

  1. It ymportearjen fan ferskate oare JS-bestannen.
  2. CSS-ymport (sadat Webpack wit se op te nimmen yn ús CSS-pakket).
  3. Te rinnen connect() om in ferbining te meitsjen mei de tsjinner en útfiere downloadAssets() om ôfbyldings te downloaden dy't nedich binne om it spultsje te werjaan.
  4. Nei it foltôgjen fan faze 3 it haadmenu wurdt werjûn (playMenu).
  5. It ynstellen fan de handler foar it drukken op de "PLAY" knop. As de knop wurdt yndrukt, initializes de koade it spul en fertelt de tsjinner dat wy binne ree om te spyljen.

De wichtichste "fleis" fan ús client-tsjinner logika is yn dy triemmen dy't waarden ymportearre troch de triem index.js. No sille wy se allegear yn oarder beskôgje.

4. Útwikseling fan klant gegevens

Yn dit spul brûke wy in bekende bibleteek om te kommunisearjen mei de tsjinner socket.io. Socket.io hat native stipe web sockets, dy't goed geskikt binne foar twa-wei kommunikaasje: wy kinne berjochten stjoere nei de tsjinner и de tsjinner kin stjoere berjochten nei ús op deselde ferbining.

Wy sille ien bestân hawwe src/client/networking.jswa sil soargje foar elkenien kommunikaasje mei de tsjinner:

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

Dizze koade is ek wat ynkoarte foar dúdlikens.

D'r binne trije haadaksjes yn dit bestân:

  • Wy besykje te ferbinen mei de tsjinner. connectedPromise allinnich tastien as wy hawwe oprjochte in ferbining.
  • As de ferbining suksesfol is, registrearje wy werombelfunksjes (processGameUpdate() и onGameOver()) foar berjochten dy't wy kinne ûntfange fan de tsjinner.
  • Wy eksportearje play() и updateDirection()sadat oare triemmen se kinne brûke.

5. Client Rendering

It is tiid om de ôfbylding op it skerm te werjaan!

...mar foardat wy dat kinne dwaan, moatte wy alle ôfbyldings (boarnen) downloade dy't hjirfoar nedich binne. Litte wy in boarnebehearder skriuwe:

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

Boarnebehear is net sa dreech om te realisearjen! It wichtichste idee is om in objekt op te slaan assets, dy't de kaai fan 'e bestânsnamme sil bine oan 'e wearde fan it objekt Image. As de boarne wurdt laden, bewarje wy it yn in objekt assets foar flugge tagong yn 'e takomst. Wannear sil elke yndividuele boarne tastien wêze om te downloaden (dat is, allegear boarnen), wy tastean downloadPromise.

Nei it downloaden fan de boarnen kinne jo begjinne mei rendering. Lykas earder sein, brûke wy om op in webside te tekenjen HTML5 Canvas (<canvas>). Us spultsje is frij ienfâldich, dus wy hoege allinich it folgjende te tekenjen:

  1. Eftergrûn
  2. Spiler skip
  3. Oare spilers yn it spul
  4. Skelpen

Hjir binne de wichtige snippets src/client/render.js, dy't krekt de fjouwer hjirboppe neamde items werjaan:

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

Dizze koade is ek ynkoarte foar dúdlikens.

render() is de haadfunksje fan dizze triem. startRendering() и stopRendering() kontrolearje de aktivearring fan de render loop by 60 FPS.

Konkrete ymplemintaasjes fan yndividuele renderingshelpfunksjes (bgl. renderBullet()) binne net sa wichtich, mar hjir is ien ienfâldich foarbyld:

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

Tink derom dat wy de metoade brûke getAsset(), dy't earder sjoen yn asset.js!

As jo ​​​​ynteressearre binne om te learen oer oare renderingshelpers, lês dan de rest. src/client/render.js.

6. Client input

It is tiid om in spultsje te meitsjen spielber! It kontrôleskema sil heul ienfâldich wêze: om de bewegingsrjochting te feroarjen, kinne jo de mûs brûke (op in kompjûter) of it skerm oanreitsje (op in mobyl apparaat). Om dit út te fieren, sille wy registrearje Harkers fan barrens foar Mouse en Touch eveneminten.
Sil soargje foar dit alles 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() binne Event Listeners dy't belje updateDirection() (fan networking.js) as in ynfier-evenemint bart (bygelyks as de mûs ferpleatst wurdt). updateDirection() behannelt berjochten mei de tsjinner, dy't de ynfier-evenemint behannelet en it spultastân dêrmei fernijt.

7. Client Status

Dizze seksje is it dreechste yn it earste diel fan 'e post. Wês net ûntmoedige as jo it net begripe de earste kear dat jo it lêze! Jo kinne it sels oerslaan en letter weromkomme.

It lêste stikje fan 'e puzel dy't nedich is om de client- / tsjinnerkoade te foltôgjen is steat. Unthâld de koade snippet út de Client Rendering seksje?

render.js

import { getCurrentState } from './state';

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

  // Do the rendering
  // ...
}

getCurrentState() moat by steat wêze om te jaan ús de hjoeddeiske steat fan it spul yn de klant op elk momint yn 'e tiid basearre op updates ûntfongen fan de tsjinner. Hjir is in foarbyld fan in spultsje-fernijing dy't de tsjinner kin stjoere:

{
  "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 spultsje-update befettet fiif identike fjilden:

  • t: Tsjinner tiidstempel oanjout wannear't dizze fernijing makke is.
  • me: Ynformaasje oer de spiler dy't dizze fernijing ûntfangt.
  • oaren: In array fan ynformaasje oer oare spilers dy't meidogge oan itselde spultsje.
  • kûgels: in array fan ynformaasje oer projektilen yn it spul.
  • Leaderboard: Aktuele leaderboardgegevens. Yn dizze post sille wy se net beskôgje.

7.1 Naive klant steat

Naive ymplemintaasje getCurrentState() kin allinnich direkt werom de gegevens fan de meast resint ûntfongen spultsje update.

naive-state.js

let lastGameUpdate = null;

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

export function getCurrentState() {
  return lastGameUpdate;
}

Moai en dúdlik! Mar as it mar sa ienfâldich wie. Ien fan 'e redenen dat dizze ymplemintaasje problematysk is: it beheint de rendering frame rate oan de server klok rate.

Byldsnelheid: oantal frames (d.w.s. oproppen render()) per sekonde, of FPS. Spultsjes stribje normaal om op syn minst 60 FPS te berikken.

Tick ​​Rate: De frekwinsje wêrop de tsjinner spultsje updates stjoert nei kliïnten. It is faak leger as de frame rate. Yn ús spultsje rint de server mei in frekwinsje fan 30 syklusen per sekonde.

As wy gewoan de lêste update fan it spultsje leverje, dan sil de FPS yn essinsje nea oer 30 gean, om't wy nea krije mear as 30 updates per sekonde fan de tsjinner. Sels as wy belje render() 60 kear per sekonde, dan sil de helte fan dizze oproppen gewoan itselde ding tekenje, yn essinsje neat dwaan. In oar probleem mei de naïve ymplemintaasje is dat it gefoelich foar fertraging. Mei in ideale ynternetsnelheid sil de klant elke 33ms (30 per sekonde) in spultsje-update krije:

It meitsjen fan in Multiplayer .io Web Game
Spitigernôch, neat is perfekt. In mear realistysk byld soe wêze:
It meitsjen fan in Multiplayer .io Web Game
De naïve ymplemintaasje is praktysk it minste gefal as it giet om latency. As in spultsje update wurdt ûntfongen mei in fertraging fan 50ms, dan klant stâlen in ekstra 50ms omdat it noch rendering it spul steat fan de foarige update. Jo kinne jo yntinke hoe ûngemaklik dit is foar de spiler: willekeurich remmen sil it spultsje jerky en ynstabyl fiele.

7.2 Ferbettere klant steat

Wy sille meitsje wat ferbetterings oan de naïve ymplemintaasje. Earst brûke wy rendering fertraging foar 100 ms. Dit betsjut dat de "aktuele" tastân fan 'e kliïnt altyd 100ms efter de steat fan it spul op 'e tsjinner sil bliuwe. Bygelyks, as de tiid op de tsjinner is 150, dan sil de kliïnt de steat werjaan wêryn de tsjinner op dat stuit wie 50:

It meitsjen fan in Multiplayer .io Web Game
Dit jout ús in buffer fan 100ms om ûnfoarspelbere spultsje-updatetiden te oerlibjen:

It meitsjen fan in Multiplayer .io Web Game
De betelling foar dit sil permanint wêze input lag foar 100 ms. Dit is in lyts opoffering foar glêde gameplay - de measte spilers (benammen casual spilers) sille dizze fertraging net iens fernimme. It is folle makliker foar minsken om oan te passen oan in konstante 100ms latency dan it is om te spyljen mei in ûnfoarspelbere latency.

Wy kinne ek brûke in oare technyk neamd klant-side foarsizzing, dy't in goede baan docht foar it ferminderjen fan waarnommen latency, mar sil net wurde behannele yn dizze post.

In oare ferbettering dy't wy brûke is lineêre ynterpolaasje. Fanwegen rendering lag, wy binne meastentiids op syn minst ien update foar de hjoeddeiske tiid yn de klant. Wannear't neamd getCurrentState(), kinne wy ​​útfiere lineêre ynterpolaasje tusken spultsje-updates krekt foar en nei de hjoeddeistige tiid yn 'e kliïnt:

It meitsjen fan in Multiplayer .io Web Game
Dit lost it probleem mei framerate op: wy kinne no unike frames werjaan op elke framerate dy't wy wolle!

7.3 Implementearje ferbettere klant steat

Implementaasje foarbyld yn src/client/state.js brûkt sawol renderlag as lineêre ynterpolaasje, mar net foar lang. Lit ús brekke de koade yn twa dielen. Hjir is de earste:

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

De earste stap is om út te finen wat currentServerTime(). Lykas wy earder seagen, omfettet elke spielupdate in servertiidstempel. Wy wolle render latency brûke om de ôfbylding 100ms efter de tsjinner te werjaan, mar wy sille nea witte de hjoeddeiske tiid op de tsjinner, om't wy net kinne witte hoe lang it duorre foardat ien fan 'e updates by ús kaam. It ynternet is ûnfoarspelber en syn snelheid kin sterk ferskille!

Om dit probleem om te kommen, kinne wy ​​​​in ridlike approximaasje brûke: wy stel as de earste update direkt oankaam. As dit wier wie, dan soene wy ​​de servertiid op dit bepaalde momint witte! Wy bewarje it tiidstempel fan de tsjinner yn firstServerTimestamp en hâld ús pleatslik (client) timestamp op itselde momint yn gameStart.

O, wachtsje. Soe it net servertiid = clienttiid wêze? Wêrom meitsje wy ûnderskied tusken "tsjinner timestamp" en "client timestamp"? Dit is in geweldige fraach! It docht bliken dat se net itselde ding binne. Date.now() sil weromkomme ferskillende timestamps yn de client en tsjinner, en it hinget ôf fan faktoaren dy't lokale oan dizze masines. Nea oannimme dat tiidstempels sille wêze itselde op alle masines.

No wy begripe wat docht currentServerTime(): it komt werom de tsjinner tiidstempel fan de aktuele render tiid. Mei oare wurden, dit is de hjoeddeiske tiid fan de tsjinner (firstServerTimestamp <+ (Date.now() - gameStart)) minus render fertraging (RENDER_DELAY).

Litte wy no ris sjen hoe't wy spielupdates behannelje. As ûntfongen fan de fernijingstsjinner, wurdt it neamd processGameUpdate()en wy bewarje de nije update nei in array gameUpdates. Dan, om it ûnthâldgebrûk te kontrolearjen, ferwiderje wy alle âlde updates earder basis updatewant wy ha se net mear nedich.

Wat is in "basis update"? Dit de earste fernijing dy't wy fine troch efterút te bewegen fan 'e hjoeddeistige tiid fan' e tsjinner. Unthâld dit diagram?

It meitsjen fan in Multiplayer .io Web Game
De spielupdate direkt links fan "Client Render Time" is de basisupdate.

Wêr wurdt de basisfernijing foar brûkt? Wêrom kinne wy ​​updates nei de basisline falle? Om dit út te finen, litte wy úteinlik beskôgje de útfiering getCurrentState():

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

Wy behannelje trije gefallen:

  1. base < 0 betsjut dat d'r gjin updates binne oant de hjoeddeistige rendertiid (sjoch ymplemintaasje hjirboppe getBaseUpdate()). Dit kin barre direkt oan it begjin fan it spul fanwege rendering lag. Yn dit gefal brûke wy de lêste ûntfongen update.
  2. base is de lêste update wy hawwe. Dit kin komme troch netwurkfertraging of minne ynternetferbining. Yn dit gefal brûke wy ek de lêste update dy't wy hawwe.
  3. Wy hawwe in update sawol foar as nei de hjoeddeistige rendertiid, dus wy kinne ynterpolearje!

Alles wat der yn bliuwt state.js is in ymplemintaasje fan lineêre ynterpolaasje dat is simpel (mar saai) wiskunde. As jo ​​​​it sels ferkenne wolle, iepenje dan state.js op Github.

Diel 2. Backend tsjinner

Yn dit diel sille wy de Node.js-backend besjen dy't ús kontrolearret .io spultsje foarbyld.

1. Server Entry Point

Om beheare de web tsjinner, wy sille brûke in populêr web ramt foar Node.js neamd Express. It sil wurde konfigureare troch ús server-yngongspuntbestân src/server/server.js:

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

Unthâld dat wy yn it earste diel Webpack besprutsen hawwe? Dit is wêr't wy ús Webpack-konfiguraasjes sille brûke. Wy sille se op twa manieren brûke:

  • Brûke webpack-dev-middleware om automatysk ús ûntwikkelingspakketten opnij op te bouwen, of
  • statysk oerdracht map dist/, wêryn Webpack ús bestannen sil skriuwe nei de produksjebou.

In oare wichtige taak server.js is om de tsjinner yn te stellen socket.iody't gewoan ferbynt mei de Express-tsjinner:

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

Nei't mei súkses in socket.io-ferbining mei de tsjinner ynsteld hat, sette wy eveneminthannelers op foar de nije socket. Event handlers behannelje berjochten ûntfongen fan kliïnten troch delegearjen nei in singleton foarwerp game:

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

Wy meitsje in .io-spiel, dus wy hawwe mar ien eksimplaar nedich Game ("Spul") - alle spilers spylje yn deselde arena! Yn 'e folgjende paragraaf sille wy sjen hoe't dizze klasse wurket. Game.

2. Game servers

Klasse Game befettet de wichtichste logika oan de tsjinner kant. It hat twa haadtaken: spiler behear и spultsje simulaasje.

Lit ús begjinne mei de earste taak, spiler behear.

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

  // ...
}

Yn dit spul sille wy de spilers identifisearje troch it fjild id harren socket.io socket (as jo yn 'e war wurde, gean dan werom nei server.js). Socket.io sels jout elke socket in unyk ta iddus dêr hoege wy ús gjin soargen oer te meitsjen. Ik sil him skilje Spiler ID.

Mei dat yn gedachten, litte wy eksimplaarfariabelen yn in klasse ûndersykje Game:

  • sockets is in foarwerp dat bynt de spiler ID oan de socket dat is ferbûn mei de spiler. It stelt ús yn steat om tagong sockets troch harren spiler IDs yn in konstante tiid.
  • players is in foarwerp dat bynt de spiler ID oan de koade> Spiler foarwerp

bullets is in array fan objekten Bullet, dy't gjin definitive oarder hat.
lastUpdateTime is it tiidstempel fan 'e lêste kear dat it spultsje is bywurke. Wy sille ynkoarten sjen hoe't it brûkt wurdt.
shouldSendUpdate is in helpfariabele. Wy sille ek it gebrûk koart sjen.
Metoaden addPlayer(), removePlayer() и handleInput() net nedich om út te lizzen, se wurde brûkt yn server.js. As jo ​​​​jo ûnthâld moatte ferfarskje, gean dan wat heger werom.

Lêste rigel constructor() opstart update syklus spultsjes (mei in frekwinsje fan 60 updates / s):

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

  // ...
}

Metoade update() befettet faaks it wichtichste stik logika fan serverside. Hjir is wat it docht, yn folchoarder:

  1. Berekkent hoe lang dt trochjûn sûnt de lêste update().
  2. Ferfarskje elk projektyl ​​en ferneatiget se as it nedich is. Wy sille de ymplemintaasje fan dizze funksjonaliteit letter sjen. Foar no is it genôch foar ús om dat te witten bullet.update() jout werom trueas it projektyl ​​moat wurde ferneatige (hy stapte út 'e arena).
  3. Updates elke spiler en spawn in projektyl ​​as it nedich is. Wy sille ek dizze ymplemintaasje letter sjen - player.update() kin in foarwerp werombringe Bullet.
  4. Kontrolearret foar botsingen tusken projektilen en spilers mei applyCollisions(), dy't in array fan projektilen werombringt dy't spilers reitsje. Foar elk projektyl ​​dat weromkomt, ferheegje wy de punten fan 'e spiler dy't it skeat (mei player.onDealtDamage()) en fuortsmite dan it projektyl ​​út 'e array bullets.
  5. Meldt en ferneatiget alle fermoarde spilers.
  6. Stjoert in spultsje update nei alle spilers elke sekonde kear doe't neamd update(). Dit helpt ús om de hjirboppe neamde helpfariabele by te hâlden. shouldSendUpdate. As update() neamd 60 kear / s, wy stjoere game updates 30 kear / s. Dus, klok frekwinsje server klok is 30 klokken / s (wy praat oer klok tariven yn it earste diel).

Wêrom stjoere game updates allinne troch de tiid ? Om kanaal te bewarjen. 30 spielupdates per sekonde is in protte!

Wêrom net gewoan belje update() 30 kear per sekonde? Om it spultsje simulaasje te ferbetterjen. De faker neamd update(), hoe krekter de spultsjesimulaasje sil wêze. Mar wês net te meisleept mei it oantal útdagings. update(), om't dit in berekkening djoere taak is - 60 per sekonde is genôch.

De rest fan de klasse Game bestiet út helper metoaden brûkt yn update():

game.js diel 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() frij simpel - it sortearret de spilers troch skoare, nimt de top fiif, en jout de brûkersnamme en skoare foar elk.

createUpdate() brûkt yn update() om spultsje updates te meitsjen dy't wurde ferspraat oan spilers. Syn wichtichste taak is om te neamen metoaden serializeForUpdate()ymplemintearre foar klassen Player и Bullet. Tink derom dat it allinnich trochjaan gegevens oan eltse spiler oer tichtstby spilers en projektilen - it is net nedich om ynformaasje oer spielobjekten te stjoeren dy't fier fan 'e spiler binne!

3. Game objekten op de tsjinner

Yn ús spultsje binne projektilen en spilers eins heul gelyk: it binne abstrakte, rûne, beweechbere spulobjekten. Om te profitearjen fan dizze oerienkomst tusken spilers en projektilen, litte wy begjinne mei it útfieren fan 'e basisklasse 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,
    };
  }
}

Der is neat yngewikkeld geande hjir. Dizze klasse sil in goed ankerpunt wêze foar de útwreiding. Litte wy sjen hoe't de klasse Bullet brûkt 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;
  }
}

Ymplemintaasje Bullet hiel koart! Wy hawwe tafoege oan Object allinich de folgjende tafoegings:

  • It brûken fan in pakket shortid foar willekeurige generaasje id projektyl.
  • It tafoegjen fan in fjild parentIDsadat jo de spiler kinne folgje dy't dit projektyl ​​makke hat.
  • It tafoegjen fan in weromkear wearde oan update(), dat is gelyk oan trueas it projektyl ​​is bûten de arena (ûnthâld wy praat oer dit yn de lêste paragraaf?).

Lit ús fierder nei Player:

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

Spilers binne komplekser as projektilen, dus in pear mear fjilden moatte wurde opslein yn dizze klasse. Syn metoade update() docht in protte wurk, benammen, jout it nij oanmakke projektyl ​​werom as der gjinien oer binne fireCooldown (ûnthâld dat wy it oer dit yn 'e foarige paragraaf hawwe?). Ek it wreidet de metoade út serializeForUpdate(), om't wy ekstra fjilden moatte opnimme foar de spiler yn 'e spultsje update.

In basisklasse hawwe Object - in wichtige stap om it werheljen fan koade te foarkommen. Bygelyks, gjin klasse Object elk spul foarwerp moat hawwe deselde útfiering distanceTo(), en al dizze ymplemintaasjes kopiearje en plakke oer meardere bestannen soe in nachtmerje wêze. Dit wurdt benammen wichtich foar grutte projekten.doe't it oantal útwreidzjen Object klassen groeie.

4. Collision detection

It iennichste wat foar ús oerbliuwt is te erkennen wannear't de projektilen de spilers reitsje! Unthâld dit stikje koade fan 'e metoade update() yn 'e klasse 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),
    );

    // ...
  }
}

Wy moatte de metoade ymplementearje applyCollisions(), dat jout alle projektilen dy't rekke spilers. Lokkich is it net sa dreech om te dwaan omdat

  • Alle botsende objekten binne sirkels, en dit is de ienfâldichste foarm om botsingsdeteksje út te fieren.
  • Wy hawwe al in metoade distanceTo(), dy't wy yn 'e foarige paragraaf yn 'e klasse ymplementearre hawwe Object.

Hjir is hoe ús ymplemintaasje fan botsingsdeteksje derút sjocht:

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

Dizze ienfâldige botsingsdeteksje is basearre op it feit dat twa sirkels botse as de ôfstân tusken harren sintra minder is as de som fan harren strielen. Hjir is it gefal wêr't de ôfstân tusken de sintra fan twa sirkels krekt lyk is oan de som fan har strielen:

It meitsjen fan in Multiplayer .io Web Game
D'r binne in pear mear aspekten om hjir te beskôgjen:

  • It projektyl ​​moat de spiler net slaan dy't it makke hat. Dit kin berikt wurde troch te fergelykjen bullet.parentID с player.id.
  • It projektyl ​​moat mar ien kear slaan yn it beheinende gefal fan meardere spilers dy't tagelyk botse. Wy sille dit probleem oplosse mei de operator break: sa gau as de spiler dy't botst mei it projektyl ​​wurdt fûn, stopje wy it sykjen en geane troch nei it folgjende projektyl.

De ein

Da's alles! Wy hawwe alles behannele wat jo witte moatte om in .io-webspultsje te meitsjen. Wat komt hjirnei? Bou jo eigen .io-spiel!

Alle foarbyldkoade is iepen boarne en pleatst op Github.

Boarne: www.habr.com

Add a comment