Többszereplős .io webjáték létrehozása

Többszereplős .io webjáték létrehozása
2015-ben jelent meg Agar.io egy új műfaj elődje lett játékok .io, melynek népszerűsége azóta nagyot nőtt. Magam is tapasztaltam a .io játékok népszerűségének növekedését: az elmúlt három évben I két ilyen műfajú játékot készített és adott el..

Ha még soha nem hallottál ezekről a játékokról, ezek ingyenes többszereplős webes játékok, amelyeket könnyű játszani (nem szükséges fiók). Általában sok ellenfél játékossal találkoznak ugyanabban az arénában. Egyéb híres .io játékok: Slither.io и Diep.io.

Ebben a bejegyzésben megvizsgáljuk, hogyan hozzon létre egy .io játékot a semmiből. Ehhez csak a Javascript ismerete elég lesz: érteni kell például a szintaxist ES6, kulcsszó this и ígéretek. Még ha a Javascript ismerete nem is tökéletes, akkor is megértheti a bejegyzés nagy részét.

.io játék példa

A tanulási segítséghez hivatkozni fogunk .io játék példa. Próbálj meg játszani!

Többszereplős .io webjáték létrehozása
A játék meglehetősen egyszerű: egy hajót irányítasz egy olyan arénában, ahol más játékosok is vannak. A hajód automatikusan lövedékeket lő ki, és te megpróbálsz eltalálni más játékosokat, miközben elkerülöd a lövedékeiket.

1. A projekt rövid áttekintése / felépítése

ajánl forráskód letöltése példajáték, hogy kövessen engem.

A példa a következőt használja:

  • expressz a legnépszerűbb Node.js webes keretrendszer, amely a játék webszerverét kezeli.
  • foglalat.io - websocket könyvtár a böngésző és a szerver közötti adatcseréhez.
  • webpack - modul menedzser. Elolvashatja, miért érdemes használni a Webpack-et. itt.

Így néz ki a projekt könyvtárszerkezete:

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

nyilvános/

Minden egy mappában public/ statikusan küldi el a szerver. BAN BEN public/assets/ projektünkben használt képeket tartalmaz.

src /

Minden forráskód a mappában van src/. Címek client/ и server/ beszéljenek magukért és shared/ konstans fájlt tartalmaz, amelyet a kliens és a szerver is importál.

2. Összeállítások/projektbeállítások

Ahogy fentebb említettük, a projekt felépítéséhez modulkezelőt használunk webpack. Vessünk egy pillantást a Webpack konfigurációra:

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:

  • src/client/index.js a Javascript (JS) kliens belépési pontja. A Webpack innen indul, és rekurzív módon keres más importált fájlokat.
  • A Webpack build kimeneti JS-je a könyvtárban található dist/. Ezt a fájlt a miénknek nevezem js csomag.
  • Használunk Babel, és különösen a konfiguráció @babel/preset-env JS kódunk régebbi böngészőkhöz való átültetéséhez.
  • Egy beépülő modul segítségével kibontjuk a JS-fájlok által hivatkozott összes CSS-t, és egy helyen egyesítjük őket. a miénknek fogom hívni css csomag.

Lehet, hogy furcsa csomagfájlneveket vett észre '[name].[contenthash].ext'. Tartalmaznak fájlnév-helyettesítések Webcsomag: [name] helyére a bemeneti pont neve kerül (esetünkben ez game), és [contenthash] helyére a fájl tartalmának hash-je kerül. Mi azért csináljuk optimalizálja a projektet kivonatolásra - megmondhatja a böngészőknek, hogy a JS-csomagjainkat korlátlan ideig tárolják, mert ha egy csomag megváltozik, akkor a fájl neve is megváltozik (változtatások contenthash). A végeredmény a nézetfájl neve lesz game.dbeee76e91a97d0c7207.js.

fájl webpack.common.js az alap konfigurációs fájl, amelyet importálunk a fejlesztési és a kész projekt konfigurációkba. Íme egy példa a fejlesztési konfigurációra:

webpack.dev.js

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

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

A hatékonyság érdekében a fejlesztési folyamatban használjuk webpack.dev.js, és átvált a webpack.prod.jsa csomagméretek optimalizálásához éles üzembe helyezéskor.

Helyi beállítás

Azt javaslom, hogy telepítse a projektet egy helyi gépre, hogy kövesse az ebben a bejegyzésben felsorolt ​​lépéseket. A beállítás egyszerű: először a rendszernek telepítve kell lennie Csomópont и NPM. Következő tennie kell

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

és készen állsz az indulásra! A fejlesztői kiszolgáló elindításához csak futtassa

$ npm run develop

és lépjen a webböngészőbe localhost: 3000. A fejlesztőszerver automatikusan újraépíti a JS- és CSS-csomagokat, amint a kód megváltozik – csak frissítse az oldalt az összes változás megtekintéséhez!

3. Ügyfél belépési pontjai

Térjünk rá magára a játék kódjára. Először is szükségünk van egy oldalra index.html, az oldal meglátogatásakor a böngésző először azt tölti be. Az oldalunk nagyon egyszerű lesz:

index.html

Példa .io játékra  JÁTÉK

Ezt a kódpéldát kissé leegyszerűsítettük az egyértelműség kedvéért, és ugyanezt fogom tenni sok más bejegyzési példával is. A teljes kód mindig megtekinthető a címen GitHub.

Nekünk van:

  • HTML5 vászon elem (<canvas>), amelyet a játék megjelenítéséhez fogunk használni.
  • <link> CSS-csomagunk hozzáadásához.
  • <script> Javascript csomagunk hozzáadásához.
  • Főmenü felhasználónévvel <input> és a PLAY gombot (<button>).

A kezdőlap betöltése után a böngésző megkezdi a Javascript kód futtatását, a belépési pont JS fájljától kezdve: 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 bonyolultan hangzik, de itt nem sok minden történik:

  1. Több más JS-fájl importálása.
  2. CSS-importálás (így a Webpack tudja, hogy ezeket bele kell foglalnia a CSS-csomagunkba).
  3. dob connect() hogy kapcsolatot létesítsen a szerverrel és futtasson downloadAssets() a játék megjeleníté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 kezelő beállítása a "PLAY" gomb megnyomására. A gomb megnyomásakor a kód inicializálja a játékot, és közli a szerverrel, hogy készen állunk a játékra.

Kliens-szerver logikánk fő "húsa" azokban a fájlokban van, amelyeket a fájl importált index.js. Most mindegyiket sorba vesszük.

4. Ügyféladatok cseréje

Ebben a játékban egy jól ismert könyvtárat használunk a szerverrel való kommunikációhoz foglalat.io. A Socket.io natív támogatással rendelkezik WebSockets, amelyek jól alkalmasak a kétirányú kommunikációra: tudunk üzeneteket küldeni a szervernek и a szerver ugyanazon a kapcsolaton tud üzeneteket küldeni nekünk.

Egy fájlunk lesz src/client/networking.jsaki gondoskodni fog mindenki kommunikáció 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('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);
};

Ezt a kódot is kissé lerövidítették az érthetőség kedvéért.

Három fő művelet van ebben a fájlban:

  • Megpróbálunk csatlakozni a szerverhez. connectedPromise csak akkor engedélyezett, ha létrejött a kapcsolat.
  • Sikeres kapcsolat esetén visszahívási funkciókat regisztrálunk (processGameUpdate() и onGameOver()) a szervertől fogadható üzenetekhez.
  • Exportálunk play() и updateDirection()hogy más fájlok is használhassák őket.

5. Client rendering

Ideje megjeleníteni a képet a képernyőn!

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

eszközök.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];

Az erőforrás-gazdálkodást nem olyan nehéz megvalósítani! A fő ötlet egy tárgy tárolása assets, amely a fájlnév kulcsát az objektum értékéhez köti Image. Amikor az erőforrás betöltődik, egy objektumban tároljuk assets a jövőbeni gyors hozzáférés érdekében. Mikor lesz engedélyezve az egyes erőforrások letöltése (vagyis minden források), megengedjük downloadPromise.

Az erőforrások letöltése után megkezdheti a renderelést. Ahogy korábban említettük, rajzolni egy weboldalon használunk HTML5 vászon (<canvas>). A játékunk nagyon egyszerű, így csak a következőket kell rajzolnunk:

  1. háttér
  2. Játékos hajó
  3. A játék többi játékosa
  4. Kagyló

Íme a fontos részletek src/client/render.js, amelyek pontosan a fent felsorolt ​​négy elemet jelenítik meg:

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

Ez a kód is le lett rövidítve az érthetőség kedvéért.

render() ennek a fájlnak a fő funkciója. startRendering() и stopRendering() szabályozza a render ciklus aktiválását 60 FPS-en.

Az egyes renderelő segédfunkciók konkrét megvalósításai (pl. renderBullet()) nem olyan fontosak, de álljon itt 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,
  );
}

Vegye figyelembe, hogy a módszert használjuk getAsset(), amely korábban látható volt asset.js!

Ha szeretne többet megtudni más renderelési segédekről, olvassa el a többit. src/client/render.js.

6. Ügyfél bevitele

Ideje játékot készíteni játszható! A vezérlési séma nagyon egyszerű lesz: a mozgás irányának megváltoztatásához használhatja az egeret (számítógépen) vagy érintse meg a képernyőt (mobileszközön). Ennek megvalósításához regisztrálni fogunk Eseményhallgatók Mouse and Touch eseményekhez.
Mindenről gondoskodni fog 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() Eseményfigyelők hívnak updateDirection() (tól től networking.js), amikor bemeneti esemény történik (például az egér mozgatásakor). updateDirection() üzeneteket kezel a szerverrel, amely kezeli a bemeneti eseményt, és ennek megfelelően frissíti a játék állapotát.

7. Ügyfél állapota

Ez a rész a legnehezebb a bejegyzés első részében. Ne csüggedj, ha első olvasáskor nem érted! Akár ki is hagyhatja, és később visszatérhet hozzá.

A kliens/szerver kód kitöltéséhez szükséges puzzle utolsó darabja voltak. Emlékszel a Client Rendering rész kódrészletére?

render.js

import { getCurrentState } from './state';

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

  // Do the rendering
  // ...
}

getCurrentState() képesnek kell lennie arra, hogy megadja nekünk a játék aktuális állapotát az ügyfélben bármely időpontban a szervertől kapott frissítések alapján. Íme egy példa egy játékfrissítésre, 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 frissítés létrehozásának időpontját jelző szerver időbélyegzője.
  • me: Információ a frissítést kapó lejátszóról.
  • mások: Információk tömbje az ugyanabban a játékban részt vevő többi játékosról.
  • golyók: egy sor információ a játékban lévő lövedékekről.
  • ranglistán: Aktuális ranglista adatok. Ebben a bejegyzésben nem foglalkozunk velük.

7.1 Naív kliens állapot

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

naiv-state.js

let lastGameUpdate = null;

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

export function getCurrentState() {
  return lastGameUpdate;
}

Szép és világos! De ha ez ilyen egyszerű lenne. Az egyik oka annak, hogy ez a megvalósítás problémás: a renderelési képkockasebességet a szerver órajelére korlátozza.

Filmkocka szám: képkockák száma (azaz hívások render()) másodpercenként vagy FPS. A játékok általában legalább 60 FPS elérésére törekednek.

Tick ​​Rate: Az a gyakoriság, amellyel a szerver játékfrissítéseket küld az ügyfeleknek. Gyakran alacsonyabb, mint a képkockasebesség. A mi játékunkban a szerver 30 tick/másodperc sebességgel fut.

Ha csak a legújabb játékfrissítést jelenítjük meg, akkor az FPS lényegében soha nem lépheti túl a 30-at, mert soha nem kapunk másodpercenként 30-nál több frissítést a szervertől. Még ha hívjuk is render() 60-szor másodpercenként, akkor ezeknek a hívásoknak a fele csak újrarajzolja ugyanazt a dolgot, lényegében nem csinál semmit. A másik probléma a naiv megvalósítással az, hogy az hajlamos a késésekre. Ideális internetsebességgel a kliens pontosan 33 ms-onként kap egy játékfrissítést (30 másodpercenként):

Többszereplős .io webjáték létrehozása
Sajnos semmi sem tökéletes. A reálisabb kép a következő lenne:
Többszereplős .io webjáték létrehozása
A naiv megvalósítás gyakorlatilag a legrosszabb eset, ha a késleltetésről van szó. Ha egy játékfrissítés 50 ms késéssel érkezik, akkor ügyfél standokon plusz 50 ms, mert még mindig az előző frissítés játékállapotát jeleníti meg. Képzelheti, milyen kényelmetlen ez a játékos számára: az önkényes fékezés miatt a játék szaggatott és instabil lesz.

7.2 Továbbfejlesztett ügyfélállapot

A naiv megvalósításon néhány javítást végzünk. Először is használjuk renderelési késleltetés 100 ms-ig. Ez azt jelenti, hogy a kliens "aktuális" állapota mindig 100 ms-mal elmarad a szerveren lévő játék állapotától. Például, ha a szerveren lévő idő 150, akkor az ügyfél azt az állapotot jeleníti meg, amelyben a szerver akkoriban volt 50:

Többszereplős .io webjáték létrehozása
Ez 100 ms-os puffert biztosít számunkra, hogy túléljük a kiszámíthatatlan játékfrissítési időket:

Többszereplős .io webjáték létrehozása
Ennek megtérülése állandó lesz bemeneti késés 100 ms-ig. Ez egy kisebb áldozat a gördülékeny játékmenet érdekében – a legtöbb játékos (különösen az alkalmi játékosok) észre sem veszi ezt a késést. Az emberek sokkal könnyebben alkalmazkodnak az állandó 100 ms-os késleltetéshez, mint egy előre nem látható késleltetéssel játszani.

Használhatunk egy másik technikát, az ún ügyféloldali előrejelzés, amely jó munkát végez az észlelt késleltetés csökkentésében, de ebben a bejegyzésben nem foglalkozunk vele.

Egy másik fejlesztés, amit használunk, az lineáris interpoláció. A megjelenítési késés miatt általában legalább egy frissítéssel megelőzzük a kliens aktuális idejét. Amikor hívják getCurrentState(), végre tudjuk hajtani lineáris interpoláció játékfrissítések között, közvetlenül az aktuális idő előtt és után a kliensben:

Többszereplős .io webjáték létrehozása
Ez megoldja a képkockasebesség problémáját: mostantól tetszőleges képkockasebességgel renderelhetünk egyedi képkockákat!

7.3 Továbbfejlesztett ügyfélállapot megvalósítása

Megvalósítási példa itt src/client/state.js renderelési késleltetést és lineáris interpolációt is használ, de nem sokáig. Osszuk két részre a kódot. Í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);

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

Az első dolog, amit meg kell tennie, hogy kitalálja, mit csinál currentServerTime(). Ahogy korábban láttuk, minden játékfrissítés tartalmaz egy szerver időbélyeget. Renderelési késleltetést szeretnénk használni a kép megjelenítéséhez 100 ms-mal a szerver mögött, de soha nem fogjuk tudni az aktuális időt a szerveren, mert nem tudhatjuk, mennyi időbe telt, mire valamelyik frissítés eljutott hozzánk. Az internet kiszámíthatatlan, sebessége nagyon változó lehet!

A probléma megkerülésére egy ésszerű közelítést használhatunk: mi Tegyük fel, hogy az első frissítés azonnal megérkezett. Ha ez igaz lenne, akkor ebben a pillanatban tudnánk a szerveridőt! Ebben tároljuk a szerver időbélyegét firstServerTimestamp és tartsuk meg a miénket helyi (kliens) időbélyegzője ugyanabban a pillanatban gameStart.

Ó várj. Nem szerveridő = kliensidő kellene? Miért teszünk különbséget a "szerver időbélyegzője" és a "kliens időbélyegzője" között? Ez egy nagyszerű kérdés! Kiderült, hogy nem ugyanazok. Date.now() különböző időbélyegeket fog visszaadni a kliensben és a kiszolgálóban, és ez a gépek helyi tényezőitől függ. Soha ne feltételezze, hogy az időbélyegek minden gépen azonosak lesznek.

Most már értjük, mi az currentServerTime(): visszatér az aktuális renderelési idő szerver időbélyege. Más szavakkal, ez a szerver aktuális ideje (firstServerTimestamp <+ (Date.now() - gameStart)) mínusz megjelenítési késleltetés (RENDER_DELAY).

Most pedig nézzük meg, hogyan kezeljük a játékfrissítéseket. Amikor frissítés érkezik a kiszolgálótól, az meghívásra kerül processGameUpdate()és elmentjük az új frissítést egy tömbbe gameUpdates. Ezután a memóriahasználat ellenőrzéséhez eltávolítjuk az összes korábbi frissítést alap frissítésmert már nincs rájuk szükségünk.

Mi az "alap frissítés"? Ez az első frissítés, amelyet úgy találunk, hogy visszafelé haladunk a szerver aktuális idejétől. Emlékszel erre a diagramra?

Többszereplős .io webjáték létrehozása
A "Client Render Time" bal oldalán található játékfrissítés az alapfrissítés.

Mire használható az alapfrissítés? Miért vethetjük le a frissítéseket az alapszintre? Hogy ezt kitaláljuk, lássuk végül fontolja meg a megvalósítást getCurrentState():

state.js 2. 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 azt jelenti, hogy nincsenek frissítések az aktuális megjelenítési időpontig (lásd fent a megvalósítást getBaseUpdate()). Ez történhet közvetlenül a játék elején a renderelési késés miatt. Ebben az esetben a legújabb frissítést használjuk.
  2. base a legújabb frissítésünk. Ennek oka lehet a hálózati késés vagy a rossz internetkapcsolat. Ebben az esetben is a legújabb frissítést használjuk.
  3. A jelenlegi renderelési idő előtt és után is van frissítésünk, így tudunk interpolál!

Minden ami benne maradt state.js a lineáris interpoláció egyszerű (de unalmas) matematikai megvalósítása. Ha saját maga szeretné felfedezni, akkor nyissa meg state.js on GitHub.

2. rész. Háttérkiszolgáló

Ebben a részben a Node.js háttérprogramot vesszük górcső alá .io játék példa.

1. Szerver belépési pont

A webszerver kezeléséhez a Node.js nevű népszerű webes keretrendszert fogjuk használni expressz. A szerver belépési pont fájlja konfigurálja src/server/server.js:

server.js 1. rész

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

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 használni őket:

  • Használat webpack-dev-middleware fejlesztői csomagjaink automatikus újraépítésére, ill
  • statikus átviteli mappa dist/, amelybe a Webpack beírja a fájljainkat a termelési build után.

Egy másik fontos feladat server.js a szerver beállítása foglalat.ioamely éppen csatlakozik az Express szerverhez:

server.js 2. rész

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

Miután sikeresen létrehoztuk a socket.io kapcsolatot a szerverrel, eseménykezelőket állítottunk be az új sockethez. Az eseménykezelők az ügyfelektől kapott üzeneteket egyetlen objektumra delegálva kezelik game:

server.js 3. rész

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

Egy .io játékot készítünk, így csak egy példányra van szükségünk Game ("Játék") - minden játékos ugyanabban az arénában játszik! A következő részben meglátjuk, hogyan működik ez az osztály. Game.

2. Játékszerverek

Osztály Game tartalmazza a szerveroldali legfontosabb logikát. Két fő feladata van: játékos menedzsment и játék szimuláció.

Kezdjük az első feladattal, a játékosmenedzsmenttel.

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 mezőny alapján azonosítjuk a játékosokat id a socket.io aljzatukat (ha összezavarodna, térjen vissza a server.js). Maga a Socket.io minden aljzatot egyedileg rendel hozzá idígy nem kell aggódnunk emiatt. fel fogom hívni Játékos azonosító.

Ezt szem előtt tartva, vizsgáljuk meg az osztály példányváltozóit Game:

  • sockets egy olyan objektum, amely a játékosazonosítót a lejátszóhoz társított aljzathoz köti. Lehetővé teszi, hogy konstans időben hozzáférjünk a csatlakozókhoz a játékosazonosítóik alapján.
  • players egy olyan objektum, amely a játékosazonosítót a kód>Játékos objektumhoz köti

bullets objektumok tömbje Bullet, amelynek nincs határozott sorrendje.
lastUpdateTime a játék legutóbbi frissítésének időbélyege. Hamarosan meglátjuk, hogyan használják.
shouldSendUpdate egy segédváltozó. Hamarosan látni fogjuk a használatát is.
mód addPlayer(), removePlayer() и handleInput() nem kell magyarázni, használatosak server.js. Ha fel kell frissítenie a memóriáját, menjen vissza egy kicsit feljebb.

Utolsó sor constructor() elindul frissítési ciklus játékok (60 frissítés/s gyakorisággal):

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

  // ...
}

módszer update() a szerveroldali logika talán legfontosabb részét tartalmazza. Íme, mit csinál, sorrendben:

  1. Kiszámolja, meddig dt elmúlt az utolsó óta update().
  2. Minden lövedéket frissít, és szükség esetén megsemmisíti. A későbbiekben látni fogjuk ennek a funkciónak a megvalósítását. Egyelőre elég, ha ezt tudjuk bullet.update() visszatér trueha a lövedéket meg kell semmisíteni (kilépett az arénából).
  3. Frissít minden játékost, és szükség esetén lövedéket hoz létre. Ezt a megvalósítást is látni fogjuk később - player.update() visszaadhat egy tárgyat Bullet.
  4. Ellenőrzi a lövedékek és a játékosok közötti ütközést applyCollisions(), amely a játékosokat eltaláló lövedékek sorát adja vissza. Minden visszaküldött lövedékért növeljük annak a játékosnak a pontjait, aki kilőtte (a player.onDealtDamage()), majd távolítsa el a lövedéket a tömbből bullets.
  5. Értesíti és megsemmisíti az összes megölt játékost.
  6. Játékfrissítést küld minden játékosnak minden másodperc amikor hívják update(). Ez segít nyomon követni a fent említett segédváltozót. shouldSendUpdate. Mint update() 60-szor hívják/s, játékfrissítéseket küldünk 30-szor/s. És így, órajel frekvenciája a szerver órajele 30 óra/s (az első részben az órajelekről beszéltünk).

Miért csak játékfrissítéseket kell küldeni? időn keresztül ? Csatorna mentéséhez. A másodpercenkénti 30 játékfrissítés sok!

Miért nem hívja csak update() 30-szor másodpercenként? A játékszimuláció javítása érdekében. A gyakrabban hívják update(), annál pontosabb lesz a játékszimuláció. De ne ragadjon el túlságosan a kihívások számától. update(), mert ez egy számításigényes feladat - 60 másodpercenként elég.

Az osztály többi tagja Game ban használt segítő módszerekből áll 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) <= 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() nagyon egyszerű – pontszám szerint rendezi a játékosokat, kiválasztja az első ötöt, és mindegyikhez visszaadja a felhasználónevet és a pontszámot.

createUpdate() használt update() játékfrissítések létrehozásához, amelyeket a játékosok között osztanak ki. Fő feladata a metódusok meghívása serializeForUpdate()osztályokhoz valósítottuk meg Player и Bullet. Vegye figyelembe, hogy minden játékosnak csak kb legközelebbi játékosok és lövedékek – nincs szükség a játékostól távol eső játéktárgyak információinak továbbítására!

3. Játékobjektumok a szerveren

A mi játékunkban a lövedékek és a játékosok valójában nagyon hasonlóak: absztrakt, kerek, mozgatható játéktárgyak. A játékosok és lövedékek közötti hasonlóság kihasználása érdekében kezdjük az alaposztály megvalósításával 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,
    };
  }
}

Nincs itt semmi bonyolult. Ez az osztály jó rögzítési pont lesz a bővítéshez. Lássuk, hogyan alakul az osztály Bullet felhasznál 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 nagyon rövid! Hozzáadtuk Object csak a következő kiterjesztések:

  • Csomag használata shortid véletlenszerű generáláshoz id lövedék.
  • Mező hozzáadása parentIDhogy nyomon tudja követni a lövedéket létrehozó játékost.
  • Visszatérési érték hozzáadása ehhez update(), ami egyenlő trueha a lövedék az arénán kívül van (emlékszel, hogy az utolsó részben beszéltünk erről?).

Menjünk tovább 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,
    };
  }
}

A játékosok összetettebbek, mint a lövedékek, ezért ebben az osztályban még néhány mezőt kell tárolni. A módszere update() sok munkát végez, különösen visszaadja az újonnan létrehozott lövedéket, ha már nincs fireCooldown (emlékszel, beszéltünk erről az előző részben?). Ezenkívül kiterjeszti a módszert serializeForUpdate(), mert a játékfrissítésben további mezőket kell tartalmaznunk a játékos számára.

Alaposztályú Object - fontos lépés a kód ismétlődésének elkerülése érdekében. Például nincs osztály Object minden játékobjektumnak ugyanazzal a megvalósítással kell rendelkeznie distanceTo(), és ezeknek a megvalósításoknak több fájlba másolása rémálom lenne. Ez különösen nagy projekteknél válik fontossá.amikor a száma bővül Object az osztályok gyarapodnak.

4. Ütközésészlelés

Nekünk már csak az marad, hogy felismerjük, amikor a lövedékek eltalálják a játékosokat! Emlékezzen erre a kódrészletre a metódusból update() osztályban 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),
    );

    // ...
  }
}

Meg kell valósítanunk a módszert applyCollisions(), amely visszaadja a játékosokat eltaláló összes lövedéket. Szerencsére nem olyan nehéz megtenni, mert

  • Minden ütköző tárgy kör, ami a legegyszerűbb alakzat az ütközésészlelés megvalósításához.
  • Már van egy módszerünk distanceTo(), amelyet az előző részben az osztályban implementáltunk Object.

Így néz ki az ütközésészlelésünk megvalósítása:

ütközések.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 az egyszerű ütközésészlelés azon a tényen alapul, hogy két kör ütközik, ha a középpontjuk távolsága kisebb, mint a sugaruk összege. Itt van az az eset, amikor két kör középpontja közötti távolság pontosan egyenlő a sugaruk összegével:

Többszereplős .io webjáték létrehozása
Itt még néhány szempontot figyelembe kell venni:

  • A lövedék nem találhatja el azt a játékost, aki létrehozta. Ezt összehasonlítással lehet elérni bullet.parentID с player.id.
  • A lövedéknek csak egyszer kell eltalálnia abban az esetben, ha egyszerre több játékos ütközik. Ezt a problémát az operátor segítségével oldjuk meg break: amint megtaláljuk a lövedéknek ütköző játékost, leállítjuk a keresést és továbblépünk a következő lövedékre.

Конец

Ez minden! Mindent leírtunk, amit egy .io webjáték létrehozásához tudnia kell. Mi a következő lépés? Építsd meg saját .io játékodat!

Minden mintakód nyílt forráskódú, és közzétételre került GitHub.

Forrás: will.com

Hozzászólás