Többjátékos .io webjáték létrehozása

Többjátékos .io webjáték létrehozása
2015-ben megjelent Agar.io az új műfaj előfutárává vált .io játékok, amelynek népszerűsége azóta jelentősen megnőtt. A .io játékok népszerűségének növekedését magam is megtapasztaltam: az elmúlt három évben két ilyen játékot hoztam létre és adtam el..

Ha még soha nem hallottál az ilyen játékokról: ezek ingyenes, többjátékos webböngészős játékok, amelyekbe könnyen be lehet kapcsolódni (nem igényel regisztrációt). Általában sok ellenfelet ütköztetnek egy arénában. Más híres .io játékok: Slither.io és Diep.io.

Ebben a posztban azt fogjuk megvizsgálni, hogy hogyan lehet .io játékot a nulláról létrehozni. Ehhez mindössze JavaScript ismeretek kellenek: meg kell értened az olyan dolgokat, mint a szintaxis ES6, a this és Promiseskulcsszó. Még ha nem is tökéletesen ismered a JavaScriptet, akkor is képes leszel megérteni a poszt nagy részét.

.io játék példa

A tanulás segítésére egy .io játék példájára fogunk hivatkozni. Próbáld ki, hogy játssz vele!

Többjátékos .io webjáték létrehozása
A játék elég egyszerű: te irányítod a hajót az arénában, ahol más játékosok is vannak. A hajód automatikusan lövi a lövedékeket, és megpróbálsz eltalálni más játékosokat, miközben elkerülöd a lövedékeiket.

1. Rövid áttekintés / a projekt felépítése

Ajánlom, hogy töltsd le a forráskódot a játék példájához, hogy követni tudj engem.

A példában a következőket használjuk:

  • Express — a legnépszerűbb webkeretrendszer Node.js-hez, amely kezeli a játék webserverét.
  • socket.io — websockets könyvtár, amely lehetővé teszi az adatok cseréjét a böngésző és a szerver között.
  • Webpack — modulkezelő. A Webpack használatának okairól olvashatsz itt.

Így néz ki a projekt könyvtárának struktúrája:

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

public/

Minden a mappában public/ statikusan lesz megosztva a szerver által. A public/assets/ mappában találhatóak a projektünk által használt képek.

src/

Az összes forráskód a src/. A nevek client/ és server/ mappában található, az pedig shared/ tartalmaz egy állandókat importáló fájlt, amelyet a kliens és a szerver is használ.

2. Build / projektbeállítások

Ahogy fentebb említettem, a projekt buildeléséhez modulkezelőt használunk. WebpackNézzük meg a Webpack konfigurációnkat:

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

A legfontosabb sorok itt a következőképpen alakulnak:

  • src/client/index.js — ez a JavaScript (JS) kliens belépési pontja. A Webpack innen kezd és rekurzívan keres más importált fájlokat.
  • A Webpack összeállításunk kimeneti JS-e a következő mappában fog elhelyezkedni dist/. Ezt a fájlt JS csomagnak fogom nevezni, CSS csomagnak,.
  • Használunk Babelitt pedig a konfigurációt @babel/preset-env fogom használni a JS kódunk régebbi böngészők számára való transpile-álásához.
  • Használunk egy plugint is, amely kinyeri az összes CSS-t, amire a JS fájlok hivatkoznak, és egy helyen egyesíti azokat. Ezt CSS csomagnak fogom nevezni. Észrevehettétek a furcsa fájlneveket,.

'[name].[contenthash].ext', amelyek tartalmazzákfájlnév-helyettesítéseket. Webpack: [name] a belépési pont nevét fogja helyettesíteni (a mi esetünkben ez game, [contenthash]). Ezeknek az értékeknek a tudása lehetővé teszi számunkra, hogy megoldjuk az egyenletet a fájl tartalmának hash-jára lesz cserélve. Ezt azért tesszük, hogy optimalizáljuk a projektet a hash-eléshez, — lehetővé téve a böngészők számára, hogy végtelen ideig gyorsítótárazzák a JS csomagjainkat, mivel ha a csomag megváltozik, a fájl neve is megváltozik (megváltozik a contenthash). A kész eredmény a következő fájlnév formátumú lesz: — ez az alap konfigurációs fájl, amelyet importálunk a fejlesztési és a kész projektek konfigurációjába. Például itt van a fejlesztési konfiguráció: const merge = require('webpack-merge'); const common = require('./webpack.common.js');module.exports = merge(common, { mode: 'development', });Hatékonyság érdekében a fejlesztési folyamat során használjuk, game.dbeee76e91a97d0c7207.js.

Fájl webpack.common.js és vált át

webpack.dev.js

, hogy optimalizálja a csomagok méretét a termelésbe való telepítés során.

Helyi beállítások webpack.dev.jsAzt javaslom, hogy telepítsd a projektet a helyi gépedre, hogy követni tudd a posztban felsorolt lépéseket. A beállítás egyszerű: először a rendszerben telepítve kell lennie webpack.prod.js. Ezután a következő parancsokat kell futtatni:

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

A telepítés után a szükséges modulok automatikusan letöltődnek. Node és NPMEzért fontos, hogy figyelemmel kísérjük a telepítési és konfigurációs folyamatot.

A telepítési útmutatók segíthetnek az esetleges hibák elkerülésében.

és készen áll a munkára! A fejlesztői kiszolgáló elindításához elegendő, ha végrehajtja

$ npm run develop

és belép a webböngészőbe a localhost:3000. A fejlesztői kiszolgáló automatikusan újraépíti a JS és CSS csomagokat a kód változásainak során - elegendő frissíteni az oldalt, hogy lássa az összes változást!

3. Ügyfélpontok

Most kezdjük el a játék kódját. Először szükségünk lesz egy oldalra index.html, amelyet a weboldal látogatásakor a böngésző elsőként tölt be. Az oldalunk meglehetősen egyszerű lesz:

index.html

<!DOCTYPE html>
<html>
<head>
  <title>Egy példa .io játék</title>
  <link type="text/css" rel="stylesheet" href="/game.bundle.css">
</head>
<body>
  <canvas id="game-canvas"></canvas>
  <script async src="/game.bundle.js"></script>
  <div id="play-menu" class="hidden">
    <input type="text" id="username-input" placeholder="Felhasználónév" />
    <button id="play-button">JÁTÉK</button>
  </div>
</body>
</html>

Ez a példa kód kissé egyszerűsített a könnyebb érthetőség érdekében, ugyanígy fogok eljárni sok más példa esetén is. A teljes kód mindig áttekinthető a Githubot.

Nálunk van:

  • HTML5 Canvas elem (<canvas>), amelyet a játék rendereléséhez fogunk használni.
  • <link> a CSS csomagunk hozzáadásához.
  • <script> a Javascript csomagunk hozzáadásához.
  • Főmenü felhasználónévvel <input> és egy «PLAY» gombbal (

A kezdőoldal betöltése után a böngésző elkezdi futtatni a Javascript kódot, kezdve a belépési JS fájltól: 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);
  };
});

Ez bonyolultnak tűnhet, de valójában nem történik itt túl sok dolog:

  1. Több más JS fájl importálása.
  2. CSS importálása (hogy a Webpack tudja, mit kell belefoglalnia a CSS csomagunkba).
  3. Indítás connect() a szerverhez való csatlakozáshoz és a kezdéshez. downloadAssets() a játék rendereléséhez szükséges képek letöltéséhez.
  4. A 3. szakasz befejezése után megjelenik a főmenü (playMenu).
  5. A «PLAY» gomb megnyomásának kezelésének beállítása. A gomb megnyomásakor a kód inicializálja a játékot, és értesíti a szervert, hogy készen állunk a játékra.

A kliens-szerver logika fő "húsát" abban a fájlban találjuk, amelyeket a index.js. most pedig végigmegyünk rajtuk sorrendben.

4. Ügyféladatcsere

Ebben a játékban a szerverrel való kommunikációhoz egy jól ismert könyvtárat használunk socket.io. A Socket.io beépített támogatást kínál WebSockets, amelyek jól alkalmasak a kétirányú kommunikációra: üzeneteket küldhetünk a szervernek és a szerver ugyanazon a kapcsolaton üzeneteket küldhet nekünk.

Egy fájlunk lesz src/client/networking.js, amely kezeli minden kommunikációt a szerverrel:

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('Csatlakozott a szerverhez!');
    resolve();
  });
});

export const connect = onGameOver => (
  connectedPromise.then(() => {
    \/\/ Callback függvények regisztrálása
    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);
};

Ez a kód érthetőség kedvéért is némileg rövidített.

Ebben a fájlban három fő művelet zajlik:

  • Megpróbálunk kapcsolódni a szerverhez. connectedPromise csak akkor teljesül, amikor létrejött a kapcsolat.
  • Ha a kapcsolat sikeresen létrejön, regisztráljuk a callback függvényeket (processGameUpdate() és onGameOver()) az üzenetekhez, amelyeket a szervertől kaphatunk.
  • Exportáljuk play() és updateDirection(), hogy más fájlok is használhassák őket.

5. Az ügyfél renderelése

Eljött az idő, hogy megjelenítsük a képet a képernyőn!

…de mielőtt ezt megtesszük, le kell töltenünk az összes szükséges képet (erőforrást). Írjunk egy erőforráskezelőt:

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(`Letöltöttük ${assetName}`);
      assets[assetName] = asset;
      resolve();
    };
    asset.src = `\/assets\/${assetName}`;
  });
}

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

Az erőforrások kezelése nem olyan bonyolult! A fő gondolat az, hogy tárolni kell egy objektumot assets, amely a fájlnév kulcsot az objektum értékéhez köti Image. Amikor az erőforrás letöltődik, elmentjük az objektumban assets a gyorsabb jövőbeli elérés érdekében. Amikor minden egyes erőforrás letöltése engedélyezett (azaz az mindent erőforrások már le vannak töltve), engedélyezzük downloadPromise.

Az erőforrások letöltése után el lehet kezdeni a renderelést. Ahogyan korábban említettük, a weboldalon való rajzoláshoz HTML5 Canvas (<canvas>). A játékunk viszonylag egyszerű, ezért elég, ha csak a következőt rajzoljuk:

  1. Háttér
  2. Játékos hajója
  3. Más játékosok, akik a játékban vannak
  4. Lőszer

Íme, a fontos szegmensek src/client/render.js, amelyek pontosan a fent említett négy pontot ábrázolják:

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;

// Szerezzük meg a vászon grafikai kontextusát
const canvas = document.getElementById('game-canvas');
const context = canvas.getContext('2d');

// Tegyük a vásznat teljes képernyővé
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;

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

  // Háttér megrajzolása
  renderBackground(me.x, me.y);

  // Minden lövedék megrajzolása
  bullets.forEach(renderBullet.bind(null, me));

  // Minden játékos megrajzolása
  renderPlayer(me, me);
  others.forEach(renderPlayer.bind(null, me));
}

// ... Segédfunkciók itt kiemelve

let renderInterval = null;
export function startRendering() {
  renderInterval = setInterval(render, 1000 / 60);
}
export function stopRendering() {
  clearInterval(renderInterval);
}

Ez a kód is leegyszerűsítve van az érthetőség kedvéért.

render() — ennek a fájlnak a fő funkciója. startRendering() és stopRendering() vezérlik a renderelési ciklus aktiválását 60 FPS sebességgel.

A renderelési segédfunkciók konkrét megvalósításai (például renderBullet()) nem annyira fontosak, de itt van egy egyszerű példa:

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

Vegyük észre, hogy használjuk a getAsset()metódust, amelyet korábban láttunk a asset.js!

Ha érdekelnek a többi renderelési segédfunkció, olvasd el a fennmaradó részt. src/client/render.js.

6. Klienstől érkező bemenet

Itt az idő, hogy a játékot játékká tegyük! A vezérlés nagyon egyszerű lesz: a mozgás irányának megváltoztatásához használhatjuk az egeret (számítógépen) vagy a képernyő érintését (mobil eszközön). Ezt megvalósítva regisztrálni fogunk Event Listenereket az Egér és Érintés eseményekhez.
Mindezt a 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() és onTouchInput() — ezek az Event Listenerek, amelyek hívják updateDirection() (a networking.js) a bemeneti esemény (például az egérmozgás) során. updateDirection() üzenetváltásban van a szerverrel, amely kezeli a bemeneti eseményt és ennek megfelelően frissíti a játék állapotát.

7. Az ügyfél állapota

Ez a szakasz a legbonyolultabb az írás első részében. Ne keseredj el, ha első olvasatra nem érted meg! Akár át is ugorhatod, és később visszatérhetsz rá.

Az utolsó darab a kirakós játékhoz, amely szükséges a kliens-szerver kód befejezéséhez— state. Emlékszel a 'Kliens renderelése' fejezet kódjára?

render.js

import { getCurrentState } from './state';

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

  // A renderelés végrehajtása
  // ...
}

getCurrentState() képest kell, hogy tudjon adni nekünk a játék aktuális állapotát a klienstől bármikor a szervertől kapott frissítések alapján. Íme egy példa a játék frissítésére, amelyet a szerver küldhet:

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

Minden játékfrissítés öt azonos mezőt tartalmaz:

  • t: a szerver időbélyegét, amely jelzi, hogy ezt a frissítést mikor hozták létre.
  • me: információ az játékosról, aki kapja ezt a frissítést.
  • others: más játékosok információinak tömbje, akik ugyanabban a játékban vesznek részt.
  • bullets: a játékban található lövedékek információinak tömbje.
  • leaderboard: a jelenlegi ranglistaadatok. Ebben a bejegyzésben ezeket nem fogjuk figyelembe venni.

7.1 Az ügyfél naiv állapota

Naiv megvalósítás getCurrentState() csak közvetlenül az utolsó kapott játékfrissítés adatait tudja visszaadni.

naive-state.js

let lastGameUpdate = null;

// Kezelje az újonnan kapott játékfrissítést.
export function processGameUpdate(update) {
  lastGameUpdate = update;
}

export function getCurrentState() {
  return lastGameUpdate;
}

Szép és érthető! De ha minden ilyen egyszerű lenne. Az egyik oka annak, hogy ez a megvalósítás problémás: korlátozza a renderelési képkockasebességet a szerver óra sebességével.

Képkockasebesség (Frame Rate): a képkockák száma (azaz a hívások render()) másodpercenként, vagy FPS. A játékokban általában legalább 60 FPS elérésére törekednek.

Óra sebessége (Tick Rate): az a gyakoriság, amellyel a szerver frissítéseket küld a klienseknek. Gyakran alacsonyabb, mint a képkockasebesség. A mi játékunkban a szerver 30 tick másodpercenként.

Ha csak az utolsó játékfrissítést rendereljük, akkor a FPS soha nem fogja meghaladni a 30-at, mert soha nem kapunk a szervertől 30 frissítést másodpercenként. Még ha másodpercenként render() 60 alkalommal is hívjuk, akkor ezeknek a hívásoknak a fele csak ugyanazt az állapotot fogja újra renderelni, lényegében nem csinálva semmit. A naiv implementáció további problémája az, hogy késlekedésnek van kitéve. Tökéletes internetsebesség mellett az ügyfél pontosan 33 ms-onként kapja meg a játékfrissítést (másodpercenként 30):

Többjátékos .io webjáték létrehozása
Sajnos semmi sem tökéletes. A valóság inkább így néz ki:
Többjátékos .io webjáték létrehozása
A naiv implementáció szinte a legrosszabb eset a késleltetés szempontjából. Ha a játékfrissítés 50 ms késlekedéssel érkezik, akkor az ügyfél 50 ms-t késlekedik , mert továbbra is az előző frissítés játékállapotát rendereli. El tudja képzelni, mennyire kényelmetlen ez a játékos számára: a véletlenszerű akadozások miatt a játék zuhanásnak és instabilnak fog tűnni.

7.2 Fejlettebb ügyfélállapot

Néhány fejlesztést fogunk bevezetni a naiv implementációba. Először is, 100 ms-os renderelési késleltetést fogunk alkalmazni. Ez azt jelenti, hogy az ügyfél "jelenlegi" állapota mindig 100 ms-mal lemarad a szerver játékállapotától. Például, ha a szerveren az idő 150, akkor az ügyfélnél a szerver azon állapota fog megjelenni, amelyben volt 50:

Többjátékos .io webjáték létrehozása
Ez egy 100 ms-os puffert ad nekünk, amely lehetővé teszi, hogy átvészeljük a játékfrissítések váratlan érkezési idejét:

Többjátékos .io webjáték létrehozása
Az árnyékolás ezért egy folyamatos bemeneti késleltetést (input lag) fog eredményezni, amely 100 ms. Ez egy jelentéktelen áldozat a zökkenőmentes játékélményért — a legtöbb játékos (különösen a casual játékosok) még csak nem is fogja észlelni ezt a késlekedést. Az emberek sokkal könnyebben alkalmazkodnak a folyamatos 100 ms-os késlekedéshez, mint a kiszámíthatatlan késlekedéshez.

Egy másik technikát is alkalmazhatunk, amelyet "ügyféloldali előrejelzésnek"hívunk, ami jól működik a percepciós késlekedés csökkentésében, de erről az írásban nem lesz szó.

Egy újabb fejlesztés, amit alkalmazunk, az a lineáris interpoláció. A renderelési késlekedés miatt általában legalább egy frissítéssel megelőzzük az aktuális időt az ügyfélnél. Amikor a getCurrentState()hívás megtörténik, akkor végezhetünk lineáris interpolációt a játék frissítései között, közvetlenül a jelenlegi időpont előtt és után az ügyfélben:

Többjátékos .io webjáték létrehozása
Ez megoldja a képkocka frekvenciával kapcsolatos problémát: most már bármilyen szükséges frekvenciával tudunk egyedi képkockákat renderálni!

7.3 A kliens állapotának javított megvalósítása

Példa a megvalósításra src/client/state.js használ renderelési késleltetést és lineáris interpolációt, de ez nem tart sokáig. Nézzük meg a kódot két részre osztva. Íme az első:

state.js, 1. rész

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

  // Csak egy játékfrissítést tartsunk meg a jelenlegi szerveridő előtt
  const base = getBaseUpdate();
  if (base > 0) {
    gameUpdates.splice(0, base);
  }
}

function currentServerTime() {
  return firstServerTimestamp + (Date.now() - gameStart) - RENDER_DELAY;
}

// Az alap frissítés indexét adja vissza, az első játékfrissítést a
// jelenlegi szerveridő előtt, vagy -1, ha 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;
}

Első lépésként tisztázzuk, hogy mit csinál currentServerTime(). Ahogy korábban láttuk, minden játékfrissítésbe bele van építve egy szerver időbélyeg. Azt szeretnénk, hogy a renderelési késleltetés miatt a képet 100 ms-ra a szervertől rendereljük, de soha nem fogjuk tudni, mi a jelenlegi idő a szerveren, mert nem tudhatjuk, mennyi ideig tartott, amíg bármelyik frissítés eljutott hozzánk. Az internet kiszámíthatatlan, és sebessége nagyon változó lehet!

Ennek a problémának a megkerülésére ésszerű közelítést alkalmazhatunk: mi tettethetjük, hogy az első frissítés azonnal érkezett. Ha ez igaz lenne, akkor tudnánk a szerver időt ebben a konkrét pillanatban! Megőrizzük a szerver időbélyegét a firstServerTimestamp változóban, és eltároljuk a helyi (ügyfél) időbélyegünket ugyanebben a pillanatban a gameStart.

változóban. Várjunk egy pillanatot. Nem kellett volna, hogy a szerveridő = az ügyfél idő? Miért különböztetjük meg a "szerver időbélyeg" és az "ügyfél időbélyeg" kifejezéseket? Ez egy nagyszerű kérdés! Kiderül, hogy nem ugyanaz. Date.now() különböző időbélyegeket fog visszaadni az ügyfél és a szerver számára, és ez a gépek lokális tényezőitől függ. Soha ne feltételezd, hogy az időbélyegek minden gépen azonosak lesznek.

Most már világos, hogy mit csinál currentServerTime(): visszatér a jelenlegi renderelési idő szerver időbélyegével.. Más szóval, ez a szerver aktuális ideje (firstServerTimestamp < + (Date.now() - gameStart)) mínusz a renderelési késleltetés (RENDER_DELAY).

Most nézzük meg, hogyan kezeljük a játék frissítéseit. Amikor frissítést kapunk a szervertől, meghívjuk processGameUpdate(), és elmentjük az új frissítést a tömbbe gameUpdates. Ezután, hogy ellenőrizzük a memóriahasználatot, eltávolítjuk az összes régi frissítést, ami az alap frissítés, mert már nincs szükségünk rájuk.

Mi is az az „alap frissítés”? Ez az első frissítés, amit találunk, amikor hátrafelé haladunk a szerver aktuális idejétől. Emlékszel erre a diagramra?

Többjátékos .io webjáték létrehozása
A játék frissítése közvetlenül a „Client Render Time” bal oldalán van, és ez az alap frissítés.

Mire szolgál az alap frissítés? Miért tudunk elvetni frissítéseket az alapító előtt? Ennek megértéséhez nézzük meg végre a megvalósítást getCurrentState():

state.js, második rész

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

Három esetet kezelünk:

  1. base < 0 az azt jelenti, hogy a jelenlegi renderelési idő előtt nincsenek frissítések (lásd fent a megvalósítást getBaseUpdate()). Ez az elején megtörténhet a játékban a renderelési késleltetés miatt. Ilyen esetben a legutolsó kapott frissítést használjuk.
  2. base — ez a legutolsó frissítés, amivel rendelkezünk. Ez internetes késleltetés vagy gyenge internetkapcsolat miatt is előfordulhat. Ilyen esetben is a legutolsó frissítést használjuk.
  3. Van frissítésünk a jelenlegi renderelési idő előtt és után is, ezért lehet interpolálni!

Minden, ami hátra maradt, az state.js — a lineáris interpoláció megvalósítása, ami egy egyszerű (de unalmas) matematika. Ha szeretnéd önállóan tanulmányozni, nyisd meg state.js Gyorsan emlékezzünk vissza, hogyan működik a moduláris aritmetika. Az óra már egy ismert koncepció. Az órák használata Githubot.

Második rész. Backend szerver

Ebben a részben a Node.js backendet nézzük meg, amely kezeli a példa játékunkat .io.

1. A szerver belépési pontja

A webkiszolgáló kezeléséhez a Node.js népszerű webkeretrendszerét fogjuk használni, amelyet Express. A beállításáért a szerver belépési pont fájlunk felelős src/server/server.js:

server.js, első rész

const express = require('express');
const webpack = require('webpack');
const webpackDevMiddleware = require('webpack-dev-middleware');
const webpackConfig = require('../../webpack.dev.js');

// Express server beállítása
const app = express();
app.use(express.static('public'));

if (process.env.NODE_ENV === 'development') {
  // Webpack beállítása fejlesztéshez
  const compiler = webpack(webpackConfig);
  app.use(webpackDevMiddleware(compiler));
} else {
  // Statikus kiszolgálás a dist/ mappából a termelés során
  app.use(express.static('dist')); 
}

// Figyelés a porton
const port = process.env.PORT || 3000;
const server = app.listen(port);
console.log(`Szerver figyel a ${port} porton`);

Emlékszel, hogy az első részben a Webpackról beszéltünk? Itt fogjuk használni a Webpack konfigurációinkat. Kétféleképpen fogjuk alkalmazni őket:

  • Használja a webpack-dev-middleware a fejlesztési csomagjaink automatikus újrafelépítésére, vagy
  • Statikusan kiszolgálni a mappát dist/, ahová a Webpack a termelés utáni fájlokat fogja írni.

Egy másik fontos feladat server.js a szerver beállítása socket.io, amely egyszerűen csatlakozik az Express szerverhez:

server.js, 2. rész

const socketio = require('socket.io');
const Constants = require('../shared/constants');

// Express beállítása
// ...
const server = app.listen(port);
console.log(`Szerver figyel a ${port} porton`);

// socket.io beállítása
const io = socketio(server);

// Csatlakozások figyelése socket.io-n
io.on('connection', socket => {
  console.log('Játékos csatlakozott!', socket.id);

  socket.on(Constants.MSG_TYPES.JOIN_GAME, joinGame);
  socket.on(Constants.MSG_TYPES.INPUT, handleInput);
  socket.on('disconnect', onDisconnect);
});

A socket.io sikeres kapcsolatának beállítása után eseménykezelőket állítunk be az új sockethez. Az eseménykezelők kezelik az ügyfelektől érkező üzeneteket, delegálva a singleton objektumnak. [contenthash]:

server.js, 3. rész

const Game = require('./game');

// ...

// A Játék beállítása
const game = new Game();

function joinGame(username) {
  game.addPlayer(this, username);
}

function handleInput(dir) {
  game.handleInput(this, dir);
}

function onDisconnect() {
  game.removePlayer(this);
}

Egy .io játékot készítünk, így csak egy példányra lesz szükségünk a Game („Játék”) – az összes játékos ugyanazon a arénán játszik! A következő részben megnézzük, hogyan működik ez az osztály. Game.

2. A szerver Game

Osztály Game tartalmazza a legfontosabb logikát a szerver oldalán. Két fő feladata van: a játékosok kezelése és a játék szimulációja.

Kezdjük az első feladattal – a játékosok kezelésével.

game.js, 1. rész

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

  \/\/ ...
}

Ebben a játékban a játékosokat a mezőn fogjuk azonosítani id a socket.io-juk alapján (ha zavaros volna, nézd meg újra a server.js). A Socket.io minden sockethez egyedi idazonosítót rendel, így ezzel nekünk nem kell foglalkoznunk. Én ezt fogom hívni a játékos azonosítója.

Ezeket az információkat megjegyezve nézzük meg az osztály példányváltozóit: Game:

  • sockets — ez egy objektum, amely a játékos azonosítóját a játékossal kapcsolatos sockethez köti. Lehetővé teszi, hogy az azonosítóik alapján folyamatos időben hozzáférjünk a socketekhez.
  • players — ez egy objektum, amely a játékos azonosítóját a code>Player objektumhoz köti.

bullets — ez egy objektumokból álló tömb, Bulletmelynek nincs meghatározott sorrendje.
lastUpdateTime — ez a játék utolsó frissítési időbélyege. Hamarosan látni fogjuk, hogyan használjuk.
shouldSendUpdate — ez egy segédváltozó. A használatát ezt követően is meglátjuk.
Módszerek addPlayer(), removePlayer() és handleInput() nem kell magyarázni, ezeket használják a server.js. Ha szükséged van egy kis felfrissítésre, nézd meg egy kicsit feljebb.

Az utolsó sor constructor() elindítja a játék frissítési ciklusát (60 frissítés/másodperc frekvenciával): game.js, második rész

game.js, második rész

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

  \/\/ ...
}

Módszer update() tartalmazza talán a legfontosabb logikai részt a szerveren. Sorban felsorolva mindazt, amit csinál:

  1. Kiszámolja, mennyi idő dt telt el az utolsó update().
  2. Frissíti minden golyót, és szükség esetén megsemmisíti őket. E funkció megvalósítását később látni fogjuk. Jelenleg annyit kell tudnunk, hogy bullet.update() a pénzeszközöket! Még dicséretet is kap 75-90% -tól a résztvevők közül, bár a projekt fejlődése nyilvánvalóan lelassult. A legmeglepőbb, hogy mindenki igaz, ha a lövedéket el kell távolítani (kiment a pályáról).
  3. Frissíti minden játékost, és szükség esetén új lövedéket hoz létre. E megvalósítást is később fogjuk látni — player.update() visszaadhat egy objektumot Bullet.
  4. Ellenőrzi a golyók és játékosok közötti ütközéseket az applyCollisions(), amely visszaadja a játékosokba beleütközött lövedékek tömbjét. Minden visszaadott lövedék esetén növeljük a lövedéket kilövő játékos pontszámát (az player.onDealtDamage()segítségével), majd eltávolítjuk a lövedéket a tömbből. bullets.
  5. Értesíti és megsemmisíti az összes megölt játékost.
  6. Minden játékosnak elküldi a játék frissítést minden második alkalommal a hívásnál update(). Ez segít nyomon követni a fent említett segédváltozót shouldSendUpdate. Mivel update() másodpercenként 60-szor hívjuk meg, játékfrissítéseket 30-szor küldünk. Így órajel frekvencia a szerver 30 órajeltaktus/másodperc (az első részben a frekvenciáról beszéltünk).

Miért küldjünk játékfrissítéseket csak minden második alkalommal? ? A sávszélesség megtakarítása érdekében. 30 játékfrissítés másodpercenként nagyon sok!

Miért ne hívnánk meg update() másodpercenként 30-szor? A játék szimulációjának javítása érdekében. Minél gyakrabban hívjuk meg, update()annál pontosabb lesz a játék szimulációja. De nem szabad túlságosan elragadtatni magunkat az invokációk számával, update()mivel ez számítási igényes feladat — 60 másodpercenként elegendő.

A klassz maradék része Game segédfüggvényekből áll, amelyeket a update():

game.js, 3. rész

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)  b.distanceTo(player)  p.serializeForUpdate()),
      bullets: nearbyBullets.map(b => b.serializeForUpdate()),
      leaderboard,
    };
  }
}

getLeaderboard() elég egyszerű – a játékosokat a pontszámuk alapján rendezi, a legjobbak közül ötöt kiválaszt, és visszaadja a felhasználónevet és a pontszámot mindegyikük számára.

createUpdate() a update() segítségével készít játékfrissítéseket, amelyeket a játékosoknak küldenek. A fő feladata a serializeForUpdate()módszerek meghívása, amelyeket a és BulletPlayer osztályokban valósítottak meg. Ne felejtsük el, hogy minden játékosnak csak a közel lévő

játékosok és lövedékek adatait küldi – nincs szükség a távoli játékelemek információinak átadására!

3. Játékelemek a szerveren 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,
    };
  }
}

Itt semmi bonyolult nem történik. Ez az osztály jó alapot nyújt a bővítésekhez. Nézzük meg, hogyan működik az osztály. Bullet használja 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;
  }

  // Igazat ad vissza, ha a lövedéket el kell távolítani
  update(dt) {
    super.update(dt);
    return this.x  Constants.MAP_SIZE || this.y  Constants.MAP_SIZE;
  }
}

Megvalósítás Bullet nagyon rövid! Hozzáadtuk a Object csak a következő bővítményeket:

  • A csomag használata shortid véletlenszerű generálásához id lövedék.
  • A parentID mező hozzáadása , hogy nyomon követhessük a lövedéket létrehozó játékost.Visszatérő érték hozzáadása a
  • , amely egyenlő update(), ha a lövedék a pálya határain kívül található (emlékszel, erről beszéltünk az előző részben?). igazTérjünk át a

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; }// Egy újonnan létrehozott lövedéket ad vissza, vagy nullát. update(dt) { super.update(dt);// Frissítsük a pontszámot this.score += dt * Constants.SCORE_PER_SECOND;// Győződjünk meg róla, hogy a játékos a határokon belül marad this.x = Math.max(0, Math.min(Constants.MAP_SIZE, this.x)); this.y = Math.max(0, Math.min(Constants.MAP_SIZE, this.y));// Lőjünk ki egy lövedéket, ha szükséges 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, }; } } amelyeket a:

player.js

A játékosok bonyolultabbak, mint a lövedékek, ezért ennek az osztálynak még néhány mezőt kell tárolnia. Az ő módszere

több munkát végez, többek között visszaadja az éppen létrehozott lövedéket, ha a update() fireCooldown (emlékszel, erről beszéltünk az előző részben?). Szintén kiterjeszti a , mert a játék frissítéséhez további mezőket kell beépítenünk a játékos számára. serializeForUpdate()Az alap osztály megléte fontos lépés a kód ismétlődésének elkerülése érdekében

. Például az osztály nélkül Object minden játékelemnek ugyanazt a megvalósítást kellene tartalmazniadistanceTo() Object , és a másolt megvalósítások szinkronizálása több fájlban rémálom lenne. Ez különösen fontos nagy projektek esetén, amikor a bővítmény osztályok száma növekszik. 4. Ütközések észlelése, когда количество расширяющих Object классов растёт.

4. Распознавание коллизий

Ami csak maradt, az az, hogy felismerjük, mikor találják el a lövedékek a játékosokat! Emlékezzünk erre a kódrészletre a metódusból update() a [class] osztályban Game:

game.js

const applyCollisions = require('.\/collisions');

class Game {
  \/\/ ...

  update() {
    \/\/ ...

    \/\/ Ütközések alkalmazása, pontszám adása a játékosoknak a lövedékek eltalálásáért
    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),
    );

    \/\/ ...
  }
}

Meg kell valósítanunk a metódust applyCollisions(), amely visszaadja az összes lövedéket, amelyek eltalálták a játékosokat. Szerencsére ezt nem nehéz megvalósítani, mert

  • Minden ütköző objektum kör, ami a legegyszerűbb alakzat az ütközések felismerésére.
  • Már van egy metódusunk Ez különösen fontos nagy projektek esetén, amelyet az előző részben valósítottunk meg a [class] osztályban. Object.

Így néz ki az ütközések felismerésének megvalósítása:

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

Ez a legegyszerűbb ütközésfelismerés azon a tényen alapul, hogy két kör akkor ütközik, ha a közöttük lévő távolság kisebb, mint a sugaraik összege.Íme egy eset, amikor a két kör középpontja közötti távolság pontosan egyenlő a sugaraik összegével:

Többjátékos .io webjáték létrehozása
Itt még néhány szempontot alaposan figyelembe kell venni:

  • A lövedéknek nem szabad eltalálnia azt a játékost, aki létrehozta. Ezt úgy érhetjük el, hogy összehasonlítjuk a bullet.parentID a player.id.
  • A lövedéknek egyszer kell csak eltalálnia, a több játékossal való egyidejű ütközés esetén. Ezt a feladatot azzal oldjuk meg, hogy break: amint megtaláljuk a játékost, aki ütközött a lövedékkel, leállítjuk a keresést, és áttérünk a következő lövedékre.

Vége

Ennyi! Minden lényeges dologról beszéltünk, ami a .io stílusú webjáték létrehozásához szükséges. Mi legyen a következő lépés? Készíts saját .io játékodat!

A példa teljes kódja nyílt forráskódú és elérhető itt: Githubot.

Forrás: habr.com

Megbízható hosting vásárlása weboldalak számára, DDoS védelemmel, VPS VDS szerverek. 🔥 Megbízható webtárhely vásárlása DDoS védelemmel, VPS VDS szerverek | ProHoster