Vytváření multiplayerové webové hry v žánru .io

Vytváření multiplayerové webové hry v žánru .io
Vydaná v roce 2015 Agar.io se stala předchůdcem nového žánru her .io, jehož popularita od té doby výrazně vzrostla. Růst popularity her .io jsem zažil na vlastní kůži: za poslední tři roky jsem vytvořil a prodal dvě hry tohoto žánru..

Pro případ, že byste o takových hrách nikdy předtím neslyšeli: jsou to zdarma dostupné multiplayerové webové hry, do kterých se snadno zapojíte (není potřeba účet). Obvykle se na jednom aréně střetává mnoho konkurenčních hráčů. Dalšími známými hrami tohoto žánru jsou: Slither.io a Diep.io.

V tomto příspěvku si ukážeme, jak od nuly vytvořit hru .io. K tomu vám stačí pouze znalost JavaScriptu: musíte rozumět takovým věcem, jako je syntaxe ES6, klíčové slovo this a Promises. I když JavaScript neznáte dokonale, stejně byste měli být schopni zvládnout většinu příspěvku.

Příklad hry .io

Abychom usnadnili učení, budeme odkazovat na příklad hry .io. Zkuste si v ní zahrát!

Vytváření multiplayerové webové hry v žánru .io
Hra je docela jednoduchá: ovládáte loď na aréně, kde jsou další hráči. Vaše loď automaticky vystřeluje projektily a vy se snažíte zasáhnout ostatní hráče, zatímco se vyhýbáte jejich projektilům.

1. Stručný přehled/struktura projektu

Doporučuji stáhnout zdrojový kód příkladu hry, abyste mi mohli následovat.

V příkladu se používá následující:

  • Express — nejpopulárnější webový framework pro Node.js, který spravuje webový server hry.
  • socket.io — websocketová knihovna pro výměnu dat mezi prohlížečem a serverem.
  • Webpack — správce modulů. O tom, proč používat Webpack, si můžete přečíst zde.

Takhle vypadá struktura adresáře projektu:

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

public/

Všechno v adresáři public/ bude staticky předáváno serverem. V public/assets/ se nacházejí obrázky používané naším projektem.

src/

Veškerý zdrojový kód se nachází v adresáři src/. Název client/ a server/ mluví sám za sebe a shared/ obsahuje soubor konstant, který je importován jak klientem, tak serverem.

2. Sestavení/parametry projektu

Jak bylo uvedeno výše, pro sestavení projektu používáme správce modulů Webpack. Podívejme se na naši konfiguraci Webpack:

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

Nejdůležitější jsou následující řádky:

  • src/client/index.js — to je vstupní bod pro klienta Javascript (JS). Webpack bude začínat odtud a rekurzivně hledat další importované soubory.
  • Výstupní JS naší Webpack sestavy bude umístěn v adresáři dist/. Budu tomu říkat náš JS balík.
  • Používáme Babel, a konkrétně konfiguraci @babel/preset-env pro transpiling našeho JS kódu pro staré prohlížeče.
  • Používáme plugin pro extrakci všech CSS, na které se odkazují JS soubory, a pro jejich sloučení na jedno místo. Budu to nazývat naším CSS balíkem..

Možná jste si všimli podivných názvů souborů balíku '[name].[contenthash].ext'. Obsahují placeholdery názvů souborů Webpack: [name] bude nahrazen názvem vstupního bodu (v našem případě je to game), a [contenthash] bude nahrazen hashem obsahu souboru. Děláme to, abychom optimalizovali projekt pro hashing, abychom mohli prohlížečům nařídit neomezené cacheování našich JS balíků, protože pokud se balík změní, změní se i jeho název souboru (změní se contenthash). Hotovým výsledkem bude název souboru ve formátu game.dbeee76e91a97d0c7207.js.

Soubor webpack.common.js — to je základní konfigurační soubor, který importujeme do konfigurací vývoje a finálního projektu. Zde je například konfigurace pro vývoj:

webpack.dev.js

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

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

Pro efektivitu používáme v procesu vývoje webpack.dev.js, a přepíná se na webpack.prod.js, aby optimalizoval velikost balíku při nasazení do produkce.

Místní nastavení

Doporučuji nainstalovat projekt na místním stroji, abyste mohli sledovat kroky uvedené v tomto příspěvku. Nastavení je jednoduché: nejprve musí být v systému nainstalováno Node a NPM. Dále je potřeba provést

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

a jste připraveni k práci! Pro spuštění vývojového serveru stačí provést

$ npm run develop

a přejít do webového prohlížeče na localhost:3000. Vývojový server bude automaticky znovu sestavovat balíčky JS a CSS během úprav kódu — jen obnovte stránku, abyste viděli všechny změny!

3. Klientské vstupní body

Pojďme se pustit do samotného kódu hry. Nejprve potřebujeme stránku index.html, která bude načtena jako první při návštěvě webu. Naše stránka bude poměrně jednoduchá:

index.html

<!DOCTYPE html>
<html>
<head>
  <title>Příklad hry .io</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="Uživatelské jméno" />
    <button id="play-button">HRÁT</button>
  </div>
</body>
</html>

Tento kód je mírně zjednodušen pro přehlednost, to samé udělám i s mnoha dalšími příklady příspěvku. Kompletní kód si můžete prohlédnout na Github.

Máme zde:

  • Prvek HTML5 Canvas (<canvas>), který budeme používat pro vykreslování hry.
  • <link> pro přidání našeho balíčku CSS.
  • <script> pro přidání našeho balíčku JavaScriptu.
  • Hlavní menu s uživatelským jménem , prvky a tlačítko "PLAY" (

Po načtení domovské stránky se v prohlížeči začne spouštět JavaScriptový kód, počínaje vstupním JS souborem: 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 = () => {
    // Hrajte!
    play(usernameInput.value);
    playMenu.classList.add('hidden');
    initState();
    startCapturingInput();
    startRendering();
    setLeaderboardHidden(false);
  };
});

To může vypadat složitě, ale ve skutečnosti se zde neděje tolik akcí:

  1. Import několika dalších JS souborů.
  2. Import CSS (aby Webpack věděl, že je třeba je zahrnout do našeho CSS balíčku).
  3. Spuštění connect() pro navázání spojení se serverem a spuštění downloadAssets() pro stažení obrázků potřebných pro vykreslování hry.
  4. Po dokončení kroku 3 se zobrazí hlavní menu (playMenu).
  5. Nastavení obsluhy kliknutí na tlačítko "PLAY". Po stisknutí tlačítka kód inicializuje hru a informuje server, že jsme připraveni hrát.

Hlavní "maso" naší klient-server logiky se nachází v těch souborech, které byly importovány souborem index.js. Teď si je probereme postupně.

4. Výměna dat klienta

V této hře pro komunikaci se serverem používáme dobře známou knihovnu socket.io. Socket.io má vestavěnou podporu WebSockets, které se dobře hodí pro obousměrnou komunikaci: můžeme posílat zprávy serveru a server nám může posílat zprávy přes stejné spojení.

Budeme mít jeden soubor src/client/networking.js, který se postará o všechny komunikace se serverem:

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('Připojeno k serveru!');
    resolve();
  });
});

export const connect = onGameOver => (
  connectedPromise.then(() => {
    // Registrace callbacků
    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);
};

Tento kód je pro srozumitelnost také mírně zkrácen.

V tomto souboru probíhají tři hlavní činnosti:

  • Zkoušíme se připojit k serveru. connectedPromise je vyřešena pouze tehdy, když navážeme spojení.
  • Pokud je spojení úspěšně navázáno, registrujeme callback funkce (processGameUpdate() a onGameOver()) pro zprávy, které můžeme od serveru přijímat.
  • Exportujeme play() a updateDirection(), aby je mohly používat jiné soubory.

5. Renderování klienta

Nastala doba, abychom na obrazovce zobrazili obrázek!

…ale než to uděláme, musíme stáhnout všechny obrázky (zdroje), které k tomu potřebujeme. Pojďme napsat správce zdrojů:

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(`Staženo ${assetName}`);
      assets[assetName] = asset;
      resolve();
    };
    asset.src = `/assets/${assetName}`;
  });
}

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

Správa zdrojů není tak složitá! Hlavní smysl spočívá v tom, uchovávat objekt assets, který bude mapovat klíč názvu souboru na hodnotu objektu Obrázek. Jakmile se zdroj nahraje, uložíme ho do objektu assets pro rychlý přístup v budoucnu. Jakmile bude povoleno stažení každého jednotlivého zdroje (to znamená, že budou nahrány všechny zdroje), povolíme downloadPromise.

Po stažení zdrojů můžeme přistoupit k renderování. Jak už bylo řečeno, k vykreslování na webové stránce používáme HTML5 Canvas (<canvas>). Naše hra je poměrně jednoduchá, takže stačí vykreslit následující:

  1. Pozadí
  2. Hráčova loď
  3. Ostatní hráče, kteří jsou ve hře
  4. Střely

Zde jsou důležité úseky src/client/render.js, které vykreslují právě výše zmíněné čtyři body:

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;

// Získejte grafický kontext plátna
const canvas = document.getElementById('game-canvas');
const context = canvas.getContext('2d');

// Nastavte plátno na celou obrazovku
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;

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

  // Vykreslení pozadí
  renderBackground(me.x, me.y);

  // Vykreslení všech střel
  bullets.forEach(renderBullet.bind(null, me));

  // Vykreslení všech hráčů
  renderPlayer(me, me);
  others.forEach(renderPlayer.bind(null, me));
}

// ... Pomocné funkce zde vyloučeny

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

Tento kód je také zkrácen pro přehlednost.

render() — to je hlavní funkce tohoto souboru. startRendering() a stopRendering() kontrolují aktivaci renderovacího cyklu při frekvenci 60 FPS.

Konkrétní implementace jednotlivých pomocných renderovacích funkcí (například renderBullet()) nejsou tak důležité, ale tady je jeden jednoduchý příklad:

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

Všimněte si, že používáme metodu getAsset(), kterou jsme již viděli v asset.js!

Pokud máte zájem o prozkoumání dalších pomocných renderovacích funkcí, přečtěte si zbytek src/client/render.js.

6. Klientský vstup

Je čas udělat hru hratelnou! Schéma ovládání bude velmi jednoduché: pro změnu směru pohybu můžete použít myš (na počítači) nebo dotyk na obrazovce (na mobilním zařízení). Abychom to realizovali, zaregistrujeme Event Listeners pro události Mouse a Touch.
O vše se postará 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() a onTouchInput() — to jsou Event Listeners, které vyvolávají updateDirection() (při networking.js) při provádění vstupní události (například při pohybu myši). updateDirection() provádí výměnu zpráv se serverem, který zpracovává události vstupu a podle toho aktualizuje stav hry.

7. Stav klienta

Tato část je nejobtížnější v první části příspěvku. Nezoufejte, pokud ji hned na poprvé nepochopíte! Můžete ji dokonce přeskočit a vrátit se k ní později.

Poslední kousek skládačky, který potřebujeme k dokončení klientsko-serverového kódu, je stav. Pamatujete si úryvek kódu z části „Rendering klienta“?

render.js

import { getCurrentState } from './state';

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

  // Provádějte rendering
  // ...
}

getCurrentState() musí být schopna nám poskytnout aktuální stav hry na klientovi v jakémkoli okamžiku na základě aktualizací, které získáváme ze serveru. Zde je příklad aktualizace hry, kterou může server zaslat:

{
  "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": "Hráč",
      "score": 3
    }
  ]
}

Každá aktualizace hry obsahuje pět stejných polí:

  • t: časové razítko serveru, které označuje moment vytvoření této aktualizace.
  • já: informace o hráči, který tuto aktualizaci získává.
  • ostatní: pole informací o dalších hráčích, kteří se účastní stejné hry.
  • projektily: pole informací o projektilu ve hře.
  • tabulka nejlepších: aktuální data tabulky nejlepších hráčů. V tomto příspěvku je nebudeme zohledňovat.

7.1 Naivní stav klienta

Naivní implementace getCurrentState() může pouze přímo vrátit data poslední přijaté aktualizace hry.

naive-state.js

let lastGameUpdate = null;

// Zpracovat nově přijatou aktualizaci hry.
export function processGameUpdate(update) {
  lastGameUpdate = update;
}

export function getCurrentState() {
  return lastGameUpdate;
}

Hezké a jasné! Ale pokud by to bylo tak snadné. Jeden z důvodů, proč je taková implementace problematická: omezuje frekvenci snímků renderování frekvencí taktů serveru.

Frekvence snímků (Frame Rate): počet snímků (tj. volání render()) za sekundu, nebo FPS. V hrách se obvykle snaží dosáhnout alespoň 60 FPS.

Frekvence taktů (Tick Rate): frekvence, s jakou server zasílá aktualizace hry klientům. Často je nižší než frekvence snímků. V naší hře server pracuje s frekvencí 30 taktů za sekundu.

Pokud budeme pouze vykreslovat poslední aktualizaci hry, FPS nikdy nepřesáhne 30, protože nikdy nedostáváme od serveru více než 30 aktualizací za sekundu. I když byste to zavolali render() 60krát za sekundu, polovina těchto volání by pouze překreslovala to samé, v podstatě by nedělala nic. Další problém s naivní implementací je, že je citlivá na zpoždění. Při ideální rychlosti internetu klient dostane aktualizaci hry každých 33 ms (30 za sekundu):

Vytváření multiplayerové webové hry v žánru .io
Bohužel nic není dokonalé. Realističtější pohled by byl takový:
Vytváření multiplayerové webové hry v žánru .io
Naivní implementace je prakticky nejhorší případ, pokud jde o zpoždění. Pokud je aktualizace hry přijata se zpožděním 50 ms, klient se zpožďuje o dalších 50 ms, protože stále vykresluje stav hry z předchozí aktualizace. Můžete si představit, jak nepříjemné to pro hráče je: kvůli náhodným zpožděním se hra zdá trhaná a nestabilní.

7.2 Vylepšený stav klienta

Pro naivní implementaci provedeme několik vylepšení. Za prvé, použijeme zpoždění vykreslování o 100 ms. To znamená, že ‚aktuální‘ stav klienta bude vždy za stavem hry na serveru o 100 ms. Například, pokud je čas na serveru 150, bude na klientovi vykreslen stav, ve kterém byl server v okamžiku 50:

Vytváření multiplayerové webové hry v žánru .io
To nám dává buffer 100 ms, což umožňuje překonat nepředvídatelnou dobu příjmu aktualizací hry:

Vytváření multiplayerové webové hry v žánru .io
Na to doplatíme trvalým zpožděním vstupu (input lag) o 100 ms. To je zanedbatelná oběť za plynulý herní zážitek — většina hráčů (zejména příležitostných) si této prodlevy ani nevšimne. Lidem se mnohem snáze přizpůsobuje trvalé zpoždění 100 ms než hraní s nepředvídatelným zpožděním.

Můžeme také použít jinou techniku nazvanou ‚klientské predikce‘, která účinně snižuje vnímaná zpoždění, ale v tomto příspěvku nebude diskutována.

Další vylepšení, které využíváme, je lineární interpolace. Díky zpoždění vykreslování obvykle obcházíme aktuální čas v klientovi alespoň o jednu aktualizaci. Když je zavoláno getCurrentState(), můžeme provést lineární interpolaci mezi aktualizacemi hry bezprostředně před a po aktuálním čase v klientovi:

Vytváření multiplayerové webové hry v žánru .io
To řeší problém s počtem snímků: nyní můžeme vykreslovat unikátní snímky libovolnou potřebnou frekvencí!

7.3 Implementace vylepšeného stavu klienta

Příklad implementace v src/client/state.js používá jak zpoždění renderování, tak lineární interpolaci, ale to netrvá dlouho. Pojďme rozdělit kód na dvě části. Zde je první:

state.js, část 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);

  // Uchováme pouze jednu hru aktualizaci před aktuálním časem serveru
  const base = getBaseUpdate();
  if (base > 0) {
    gameUpdates.splice(0, base);
  }
}

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

// Vrací index základní aktualizace, první herní aktualizace před
// aktuálním serverovým časem, nebo -1 pokud 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;
}

Nejprve se musíme vyrovnat s tím, co dělá currentServerTime(). Jak jsme viděli dříve, každé aktualizaci hry je přidán serverový časový údaj. Chceme použít zpoždění renderování, abychom vykreslovali obrázek se zpožděním 100 ms za serverem, ale nikdy se nedozvíme, jaký je aktuální čas na serveru, protože nemůžeme vědět, jak dlouho trvalo, než k nám dorazila jakákoli ze aktualizací. Internet je nepředvídatelný a jeho rychlost se může velmi lišit!

Abychom se této problematice vyhnuli, můžeme použít rozumnou aproximaci: budeme předstírat, že první aktualizace dorazila okamžitě. Kdyby to byla pravda, věděli bychom čas serveru v tuto konkrétní chvíli! Uložíme serverový časový údaj do firstServerTimestamp a uložíme naši lokální (klientskou) časovou značku ve stejném okamžiku do gameStart.

Omlouvám se, chvíli počkejte. Nemělo by být čas na serveru = čas v klientovi? Proč odlišujeme „časový údaj serveru“ a „časový údaj klienta“? To je vynikající otázka! Ukazuje se, že to není totéž. Date.now() bude vracet různé časové údaje v klientovi i serveru a závisí to na lokálních faktorech těchto strojů. Nikdy nepředpokládejte, že časové údaje budou stejné na všech strojích.

Nyní chápeme, co dělá currentServerTime(): vrátí časový údaj serveru aktuálního času renderování. Jinými slovy, je to aktuální čas serveru (firstServerTimestamp + (Date.now() - gameStart)) mínus zpoždění renderování (RENDER_DELAY).

Teď se pojďme podívat, jak zpracováváme aktualizace hry. Po obdržení aktualizace ze serveru se volá processGameUpdate(), a nové aktualizace uložení do pole gameUpdates. Poté, abychom zkontrolovali využití paměti, odstraníme všechny staré aktualizace až do základní aktualizace, protože už je nepotřebujeme.

Co to vlastně je „základní aktualizace“? Je to první aktualizace, kterou najdeme, když se posuneme zpět od aktuálního času serveru. Pamatujete si tuto schéma?

Vytváření multiplayerové webové hry v žánru .io
Aktualizace hry přímo vlevo od „Client Render Time“ je základní aktualizace.

K čemu se základní aktualizace používá? Proč můžeme zahodit aktualizace až k základní? Abychom na to přišli, podívejme se na konečně implementaci getCurrentState():

state.js, část 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),
    };
  }
}

Zpracováváme tři případy:

  1. base < 0 označuje, že k aktuálnímu času renderování nejsou žádné aktualizace (viz výše implementaci getBaseUpdate()). K tomu může dojít hned na začátku hry kvůli zpoždění renderování. V takovém případě použijeme poslední obdrženou aktualizaci.
  2. base je nejnovější aktualizace, kterou máme. K tomu může dojít kvůli síťovému zpoždění nebo špatnému připojení k internetu. V takovém případě také použijeme poslední aktualizaci, kterou máme.
  3. Máme aktualizaci jak před, tak po aktuálním čase renderování, takže můžeme interpolovat!

Vše, co zbývá v state.js je implementace lineární interpolace, což je jednoduchá (ale nudná) matematika. Pokud se chcete naučit s tím pracovat, podívejte se na state.js na Github.

Část 2. Backendový server

V této části se podíváme na backend Node.js, který spravuje náš příklad hry .io.

1. Vstupní bod serveru

Pro správu webového serveru použijeme populární webový framework pro Node.js nazvaný Express. Jeho nastavení se postará náš vstupní bod serveru src/server/server.js:

server.js, část 1

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

// Nastavení Express serveru
const app = express();
app.use(express.static('public'));

if (process.env.NODE_ENV === 'development') {
  // Nastavení Webpack pro vývoj
  const compiler = webpack(webpackConfig);
  app.use(webpackDevMiddleware(compiler));
} else {
  // Statické servírování složky dist\/ v produkci
  app.use(express.static('dist'));
}

// Naslouchání na portu
const port = process.env.PORT || 3000;
const server = app.listen(port);
console.log(`Server naslouchá na portu ${port}`);

Pamatujte, že jsme v první části diskutovali o Webpacku? Právě zde budeme používat naše konfigurace Webpack. Aplikujeme je dvěma způsoby:

  • Použít webpack-dev-middleware pro automatické přepracování našich vývojových balíčků, nebo
  • Staticky předávat složku dist/, do které Webpack zapíše naše soubory po sestavení produkce.

Další důležitý úkol server.js spočívá v nastavení serveru socket.io, který se jednoduše připojuje k serveru Express:

server.js, část 2

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

// Nastavení Express
// ...
const server = app.listen(port);
console.log(`Server naslouchá na portu ${port}`);

// Nastavení socket.io
const io = socketio(server);

// Naslouchání na socket.io připojeních
io.on('connection', socket => {
  console.log('Hráč připojen!', socket.id);

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

Po úspěšném navázání spojení socket.io se serverem nastavujeme zpracovatele událostí pro nový socket. Zpracovatelé událostí zpracovávají zprávy přijímané od klientů delegováním na singleton objekt game:

server.js, část 3

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

// ...

// Nastavení hry
const game = new Game();

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

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

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

Vytváříme hru typu .io, takže nám bude stačit pouze jedna instance Game („Hra“) – všichni hráči hrají na jedné aréně! V další části se podíváme, jak tento třída funguje Game.

2. Serverová hra

Třída Game má nejdůležitější logiku na straně serveru. Má dvě hlavní úkoly: správa hráčů a simulace hry.

Začněme s prvním úkolem – s řízením hráčů.

game.js, část 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;

    // Vytvoření pozice pro začátek tohoto hráče.
    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);
    }
  }

  // ...
}

V této hře budeme identifikovat hráče pomocí pole id jeho socketu socket.io (pokud jste zmatení, vraťte se k server.js). Socket.io sám přiřazuje každému socketu jedinečný id, takže se o to nemusíme starat. Budu to nazývat ID hráče.

Jakmile si to zapamatujete, pojďme prozkoumat proměnné instance v třídě Game:

  • sockets — je to objekt, který mapuje ID hráče na socket, který je spojen s hráčem. Umožňuje nám přístup k socketům podle jejich ID hráčů v konstantním čase.
  • players — je to objekt, který mapuje ID hráče na objekt code>Player

projektily — je to pole objektů Bullet, které nemá žádný konkrétní pořádek.
lastUpdateTime — je to časová značka poslední aktualizace hry. Brzy uvidíme, jak se používá.
shouldSendUpdate — je to pomocná proměnná. Její použití brzy také uvidíme.
Metody addPlayer(), removePlayer() a handleInput() nevyžadují další vysvětlení, používají se v server.js. Pokud potřebujete osvěžit paměť, vraťte se o kus výše.

Poslední řádek constructor() spouští aktualizační smyčku hry (s frekvencí 60 aktualizací/s):

game.js, část 2

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

class Game {
  \/\/ ...

  update() {
    \/\/ Vypočítat uplynulý čas
    const now = Date.now();
    const dt = (now - this.lastUpdateTime) \/ 1000;
    this.lastUpdateTime = now;

    \/\/ Aktualizuje každou střelu
    const bulletsToRemove = [];
    this.bullets.forEach(bullet => {
      if (bullet.update(dt)) {
        \/\/ Zničí tuto střelu
        bulletsToRemove.push(bullet);
      }
    });
    this.bullets = this.bullets.filter(
      bullet => !bulletsToRemove.includes(bullet),
    );

    \/\/ Aktualizuje každého hráče
    Object.keys(this.sockets).forEach(playerID => {
      const player = this.players[playerID];
      const newBullet = player.update(dt);
      if (newBullet) {
        this.bullets.push(newBullet);
      }
    });

    \/\/ Aplikuje kolize, dává hráčům skóre za zásahy do střel
    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),
    );

    \/\/ Kontroluje, zda jsou nějací hráči mrtví
    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);
      }
    });

    \/\/ Posílá každému hráči aktualizaci hry jednou za dvě doby
    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;
    }
  }

  \/\/ ...
}

Metoda update() obsahuje pravděpodobně nejdůležitější část logiky na straně serveru. Pořadí je následující:

  1. Vypočítá, kolik času dt uplynulo od posledního update().
  2. Aktualizuje každý náboj a v případě potřeby je ničí. Realizaci této funkce uvidíme později. Prozatím stačí vědět, že bullet.update() vrací true, pokud má být náboj zničen (vyšel za hranice arény).
  3. Aktualizuje každého hráče a v případě potřeby vytvoří náboj. Tuto realizaci také uvidíme později — player.update() může vrátit objekt Bullet.
  4. Kontroluje kolize mezi náboji a hráči pomocí applyCollisions(), který vrací pole nábojů, které zasáhly hráče. Pro každý vrácený náboj zvyšujeme body hráče, který jej vystřelil (pomocí player.onDealtDamage()), a pak odstraníme náboj z pole projektily.
  5. Upozorňuje a ničí všechny mrtvé hráče.
  6. Odesílá všem hráčům aktualizaci hry každý druhý krát při volání update(). To nám pomáhá sledovat výše zmíněnou pomocnou proměnnou shouldSendUpdate. Takže update() volá se 60krát/s, aktualizace hry posíláme 30krát/s. Tímto způsobem, frekvence hodin serveru je 30 taktů/s (o frekvenci hodin jsme hovořili v první části).

Proč posílat aktualizace hry pouze pořádku ? Pro úsporu šířky pásma. 30 aktualizací hry za sekundu – to je opravdu hodně!

Proč by se tedy nemělo volat update() 30krát za sekundu? Pro zlepšení simulace hry. Čím častěji je voláno update(), tím přesnější bude simulace hry. Ale neměli bychom to přehánět s počtem volání update(), protože to je výpočetně náročný úkol — 60krát za sekundu je naprosto dost.

Zbytek třídy Game se skládá z pomocných metod používaných v update():

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

getLeaderboard() je poměrně jednoduchá – seřadí hráče podle počtu bodů, vezme pět nejlepších a vrátí pro každého uživatelské jméno a skóre.

createUpdate() se používá v update() k vytváření aktualizací hry, které jsou předávány hráčům. Jeho hlavní úkol je volání metod serializeForUpdate(), implementovaných pro třídy Player a Bullet. Všimněte si, že každému hráči posílá pouze informace o blízkých hráčích a střelách – není třeba předávat informace o herních objektech, které jsou od hráče daleko!

3. Herní objekty na serveru

V naší hře jsou střely a hráči skutečně velmi podobní: jsou to abstraktní kulaté pohyblivé herní objekty. Abychom využili tuto podobnost mezi hráči a střelami, začněme realizací základní třídy 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,
    };
  }
}

Není to nic složitého. Tato třída bude dobrým výchozím bodem pro rozšíření. Podívejme se, jak třída Bullet používá 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;
  }

  \/\/ Vrátí true, pokud by měl být projektil zničen
  update(dt) {
    super.update(dt);
    return this.x  Constants.MAP_SIZE || this.y  Constants.MAP_SIZE;
  }
}

Implementace Bullet je velmi krátká! Přidali jsme k Object pouze následující rozšíření:

  • Použití balíčku shortid pro náhodné generování id projektilu.
  • Přidání pole parentID, aby bylo možné sledovat hráče, který tento projektil vytvořil.
  • Přidání návratové hodnoty do update(), která je rovna true, pokud je projektil mimo arénu (pamatujte, o tom jsme hovořili v minulém oddílu?).

Přejdeme k 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;
  }

  \/\/ Vrací nově vytvořený projektil nebo null.
  update(dt) {
    super.update(dt);

    \/\/ Aktualizace skóre
    this.score += dt * Constants.SCORE_PER_SECOND;

    \/\/ Ujistěte se, že hráč zůstává v mezích
    this.x = Math.max(0, Math.min(Constants.MAP_SIZE, this.x));
    this.y = Math.max(0, Math.min(Constants.MAP_SIZE, this.y));

    \/\/ Vystřelit projektil, pokud je to nutné
    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,
    };
  }
}

Hráči jsou složitější než projektily, proto by třída měla obsahovat ještě několik dalších polí. Jeho metoda update() vykonává větší práci, zejména vrací právě vytvořený projektil, pokud nezůstalo fireCooldown (pamatujte, o tom jsme hovořili v minulém oddílu?). Také rozšiřuje metodu serializeForUpdate(), protože musíme do aktualizace hry zahrnout další pole pro hráče.

Mít základní třídu Object je důležitým krokem k tomu, abychom se vyhnuli opakování kódu. Například bez třídy Object by každý herní objekt musel mít stejnou implementaci distanceTo(), a synchronizace kopírování a vkládání všech těchto implementací v několika souborech by byla noční můra. To se stává zvlášť důležité pro velké projekty, kdy se počet rozšiřujících Object tříd zvyšuje.

4. Rozpoznávání kolizí

Jediné, co nám zbývá, je rozpoznat, kdy granáty zasáhnou hráče! Vzpomněňte si na tento úryvek kódu z metody update() v třídě Game:

game.js

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

class Game {
  // ...

  update() {
    // ...

    // Aplikovat kolize, udělit hráčům skóre za zásah granáty
    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),
    );

    // ...
  }
}

Musíme implementovat metodu applyCollisions(), která vrací všechny granáty, které zasáhly hráče. Naštěstí to není tak složité udělat, protože

  • Všechny kolidující objekty jsou kruhy, a to je nejjednodušší tvar pro implementaci rozpoznávání kolizí.
  • Máme již metodu distanceTo(), kterou jsme v předchozí části implementovali v třídě Object.

Takto vypadá naše implementace rozpoznávání kolizí:

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

Tohle jednoduché rozpoznávání kolizí je založeno na tom, že dva kruhy se srážejí, pokud je vzdálenost mezi jejich středy menší než součet jejich poloměrů. Tady je případ, kdy je vzdálenost mezi středy dvou kruhů přesně rovna součtu jejich poloměrů:

Vytváření multiplayerové webové hry v žánru .io
Zde je třeba věnovat pozornost ještě několika aspektům:

  • Granát nesmí zasáhnout hráče, který ho vytvořil. Toho dosáhneme porovnáním bullet.parentID s player.id.
  • Granát by měl zasáhnout pouze jednou v případě současného střetu s několika hráči. Tento problém vyřešíme s pomocí příkazu break: jakmile najdeme hráče, který se s granátem srazil, zastavíme hledání a přejdeme k dalšímu granátu.

Konec

A je to! Probrali jsme vše, co je potřebné znát pro vytvoření webové hry žánru .io. Co dál? Vytvořte si vlastní hru .io!

Celý kód příkladu je otevřený a zveřejněný na Github.

Zdroj: habr.com

Koupit spolehlivý hosting pro webové stránky s ochranou proti DDoS, VPS VDS servery 🔥 Koupit spolehlivý hosting pro webové stránky s ochranou proti DDoS, VPS VDS servery | ProHoster