Vytvorenie webovej hry pre viacerých hráčov v žánri .io

Vytvorenie webovej hry pre viacerých hráčov v žánri .io
Vydané v roku 2015 Agar.io sa stal predchodcom nového žánru games.io, ktorého popularita odvtedy výrazne vzrástla. Sám som zažil nárast popularity hier .io: za posledné tri roky som vytvoril a predal dve hry v tomto žánri..

V prípade, že ste o týchto hrách ešte nikdy nepočuli, sú to bezplatné webové hry pre viacerých hráčov, ktoré sa jednoducho hrajú (nevyžaduje sa žiadny účet). Zvyčajne postavia veľa súperových hráčov v jednej aréne. Ďalšie známe .io hry: Slither.io и Diep.io.

V tomto príspevku zistíme, ako na to vytvorte hru .io od začiatku. Na to vám bude stačiť iba znalosť Javascriptu: musíte rozumieť veciam, ako je syntax ES6, kľúčové slovo this и Sľuby. Aj keď Javascript dokonale nepoznáte, väčšine príspevku stále rozumiete.

Príklad hry .io

Pre pomoc pri výcviku sa odkážeme príklad hry .io. Skúste si to zahrať!

Vytvorenie webovej hry pre viacerých hráčov v žánri .io
Hra je celkom jednoduchá: ovládate loď v aréne s ostatnými hráčmi. Vaša loď automaticky vystreľuje projektily a vy sa snažíte zasiahnuť ostatných hráčov a vyhýbať sa ich projektilom.

1. Stručný prehľad/štruktúra projektu

odporučiť stiahnuť zdrojový kód príklad hry, aby ste ma mohli sledovať.

Príklad používa nasledovné:

  • expresné je najobľúbenejší webový rámec pre Node.js, ktorý spravuje webový server hry.
  • socket.io — knižnica websocket na výmenu údajov medzi prehliadačom a serverom.
  • WebPack - manažér modulov. Môžete si prečítať o tom, prečo používať Webpack tu.

Takto vyzerá adresárová štruktúra projektu:

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

verejné/

Všetko je v priečinku public/ budú staticky prenášané serverom. IN public/assets/ obsahuje obrázky používané v našom projekte.

src /

Všetok zdrojový kód je v priečinku src/. Tituly client/ и server/ hovoriť za seba a shared/ obsahuje súbor konštánt importovaný klientom aj serverom.

2. Parametre zostáv/projektu

Ako je uvedené vyššie, na zostavenie projektu používame správcu modulov WebPack. Poďme sa pozrieť na našu konfiguráciu 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',
    }),
  ],
};

Najdôležitejšie riadky sú tu:

  • src/client/index.js je vstupný bod klienta Javascript (JS). Webpack začne odtiaľto a rekurzívne hľadá ďalšie importované súbory.
  • Výstupný JS nášho zostavenia Webpack bude umiestnený v adresári dist/. Tento súbor nazvem náš JS balík.
  • Používame Babela najmä konfiguráciu @babel/preset-env na transpiláciu nášho kódu JS pre staršie prehliadače.
  • Používame plugin na extrahovanie všetkých CSS, na ktoré odkazujú súbory JS, a ich spojenie na jedno miesto. Nazvem to naše CSS balík.

Možno ste si všimli zvláštne názvy súborov balíkov '[name].[contenthash].ext'. Obsahujú nahradenie názvu súboru Webový balíček: [name] bude nahradený názvom vstupného bodu (v našom prípade je game), a [contenthash] bude nahradený hashom obsahu súboru. Toto robíme optimalizovať projekt na hashovanie - môžeme povedať prehliadačom, aby ukladali naše balíčky JS do vyrovnávacej pamäte na neurčito, pretože ak sa balík zmení, zmení sa aj názov jeho súboru (zmeny contenthash). Konečným výsledkom bude názov súboru zobrazenia game.dbeee76e91a97d0c7207.js.

súbor webpack.common.js - Toto je základný konfiguračný súbor, ktorý importujeme do vývojových a hotových konfigurácií projektu. Tu je napríklad konfigurácia vývoja:

webpack.dev.js

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

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

Pre efektivitu používame v procese vývoja webpack.dev.jsa prepne na webpack.prod.js, aby sa optimalizovali veľkosti balíkov pri nasadení do produkcie.

Miestne nastavenie

Odporúčam nainštalovať projekt na lokálny počítač, aby ste mohli postupovať podľa krokov uvedených v tomto príspevku. Nastavenie je jednoduché: po prvé, systém musí mať Uzol и NPM. Ďalej musíte urobiť

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

a ste pripravení ísť! Ak chcete spustiť vývojový server, stačí spustiť

$ npm run develop

a prejdite do webového prehliadača localhost: 3000. Vývojový server automaticky prebuduje balíky JS a CSS, keď sa vyskytnú zmeny v kóde - stačí obnoviť stránku, aby ste videli všetky zmeny!

3. Vstupné body klientov

Poďme k samotnému kódu hry. Najprv potrebujeme stránku index.html, keď stránku navštívite, prehliadač ju načíta ako prvú. Naša stránka bude celkom jednoduchá:

index.html

Príklad .io hry  HRAŤ

Tento príklad kódu bol kvôli prehľadnosti mierne zjednodušený a urobím to isté s mnohými ďalšími príkladmi v príspevku. Úplný kód si môžete vždy pozrieť na GitHub.

Máme:

  • Prvok plátna HTML5 (<canvas>), ktorý použijeme na vykreslenie hry.
  • <link> pridať náš balík CSS.
  • <script> pridať náš balík Javascript.
  • Hlavné menu s užívateľským menom <input> a tlačidlo „PLAY“ (<button>).

Po načítaní domovskej stránky prehliadač začne spúšťať kód Javascript, počnúc súborom JS vstupného bodu: 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);
  };
});

Môže to znieť komplikovane, ale v skutočnosti sa tu toho veľa nedeje:

  1. Importujte niekoľko ďalších súborov JS.
  2. Importujte CSS (aby ich Webpack vedel zahrnúť do nášho balíka CSS).
  3. Запуск connect() nadviazať spojenie so serverom a spustiť downloadAssets() na stiahnutie obrázkov potrebných na vykreslenie hry.
  4. Po dokončení fázy 3 zobrazí sa hlavné menu (playMenu).
  5. Nastavenie obsluhy kliknutia na tlačidlo „PLAY“. Po stlačení tlačidla kód inicializuje hru a oznámi serveru, že sme pripravení hrať.

Hlavné „mäso“ našej logiky klient-server je v tých súboroch, ktoré boli importované súborom index.js. Teraz sa na ne pozrieme v poradí.

4. Výmena klientskych údajov

V tejto hre používame na komunikáciu so serverom známu knižnicu socket.io. Socket.io má vstavanú podporu WebSockets, ktoré sú vhodné na obojsmernú komunikáciu: môžeme posielať správy na server и server nám môže posielať správy cez rovnaké pripojenie.

Budeme mať jeden súbor src/client/networking.jskto sa postará všetkým komunikácia so serverom:

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

Tento kód je tiež mierne skrátený kvôli prehľadnosti.

V tomto súbore sa dejú tri hlavné veci:

  • Pokúšame sa pripojiť k serveru. connectedPromise povolené iba vtedy, keď sme nadviazali spojenie.
  • Ak je pripojenie úspešné, zaregistrujeme funkcie spätného volania (processGameUpdate() и onGameOver()) pre správy, ktoré môžeme prijímať zo servera.
  • Vyvážame play() и updateDirection()aby ich mohli používať iné súbory.

5. Klientsky rendering

Je čas zobraziť obrázok na obrazovke!

...ale predtým, ako to urobíme, musíme stiahnuť všetky obrázky (zdroje), ktoré sú na to potrebné. Napíšme správcu zdrojov:

aktíva.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];

Riadenie zdrojov nie je také ťažké implementovať! Hlavným bodom je uloženie objektu assets, ktorý naviaže kľúč názvu súboru na hodnotu objektu Image. Keď je zdroj načítaný, uložíme ho do objektu assets pre rýchle prijatie v budúcnosti. Kedy bude povolené sťahovanie každého jednotlivého zdroja (t. j. sťahovanie všetko zdroje), povoľujeme downloadPromise.

Po stiahnutí zdrojov môžete začať s vykresľovaním. Ako už bolo povedané, kresliť na webovú stránku, ktorú používame Plátno HTML5 (<canvas>). Naša hra je pomerne jednoduchá, takže nám stačí vykresliť nasledovné:

  1. pozadia
  2. Hráčska loď
  3. Ostatní hráči v hre
  4. strelivo

Tu sú dôležité časti src/client/render.js, ktoré vykresľujú presne štyri body uvedené vyššie:

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

Tento kód je tiež skrátený kvôli prehľadnosti.

render() je hlavnou funkciou tohto súboru. startRendering() и stopRendering() ovládať aktiváciu vykresľovacieho cyklu pri 60 FPS.

Konkrétne implementácie jednotlivých pomocných funkcií vykresľovania (napr renderBullet()) nie sú také dôležité, ale tu je jeden jednoduchý prí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šimnite si, že používame metódu getAsset(), ktorý bol predtým videný v asset.js!

Ak máte záujem preskúmať ďalšie pomocné funkcie vykresľovania, prečítajte si zvyšok src/client/render.js.

6. Vstup klienta

Je čas urobiť hru hrateľné! Schéma ovládania bude veľmi jednoduchá: na zmenu smeru pohybu môžete použiť myš (na počítači) alebo sa dotknúť obrazovky (na mobilnom zariadení). Aby sme to mohli implementovať, zaregistrujeme sa Poslucháči udalostí pre udalosti Myš a dotyk.
O toto všetko sa 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() и onTouchInput() sú Poslucháči udalostí, ktorí volajú updateDirection() (z networking.js), keď nastane vstupná udalosť (napríklad pri pohybe myšou). updateDirection() sa zaoberá výmenou správ so serverom, ktorý spracováva vstupnú udalosť a podľa toho aktualizuje stav hry.

7. Stav klienta

Táto časť je najťažšia v prvej časti príspevku. Nenechajte sa odradiť, ak tomu pri prvom čítaní nerozumiete! Môžete ho dokonca preskočiť a vrátiť sa k nemu neskôr.

Posledný kúsok skladačky potrebný na dokončenie kódu klient-server je stať. Pamätáte si útržok kódu zo sekcie Klientské vykresľovanie?

render.js

import { getCurrentState } from './state';

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

  // Do the rendering
  // ...
}

getCurrentState() by nám mal vedieť poskytnúť aktuálny herný stav v klientovi kedykoľvek na základe aktualizácií prijatých zo servera. Tu je príklad aktualizácie hry, ktorú môže server odoslať:

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

Každá aktualizácia hry obsahuje päť rovnakých polí:

  • t: Časová pečiatka servera označujúca, kedy bola táto aktualizácia vytvorená.
  • me: Informácie o hráčovi, ktorý dostáva túto aktualizáciu.
  • ďalšie: Súbor informácií o ostatných hráčoch zúčastňujúcich sa tej istej hry.
  • guľky: množstvo informácií o projektiloch v hre.
  • leaderboard: Údaje aktuálneho rebríčka. V tomto príspevku ich nebudeme brať do úvahy.

7.1 Naivný stav klienta

Naivná implementácia getCurrentState() môže priamo vrátiť iba údaje z poslednej prijatej aktualizácie hry.

naive-state.js

let lastGameUpdate = null;

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

export function getCurrentState() {
  return lastGameUpdate;
}

Krásne a jasné! Ale keby to bolo také jednoduché. Jedným z dôvodov, prečo je táto implementácia problematická: obmedzuje rýchlosť vykresľovania na rýchlosť hodín servera.

Snímok za sekundu: počet snímok (t. j. hovorov render()) za sekundu alebo FPS. Hry sa zvyčajne snažia dosiahnuť aspoň 60 FPS.

Miera začiarknutia: Frekvencia, s akou server odosiela aktualizácie hier klientom. Často je nižšia ako snímková frekvencia. V našej hre beží server rýchlosťou 30 tikov za sekundu.

Ak len vykreslíme najnovšiu aktualizáciu hry, FPS v podstate nikdy nebude môcť prekročiť 30, pretože nikdy nedostaneme zo servera viac ako 30 aktualizácií za sekundu. Aj keď voláme render() 60-krát za sekundu, potom polovica týchto hovorov jednoducho prekreslí to isté, v podstate neurobí nič. Ďalším problémom naivnej implementácie je, že to podlieha omeškaniam. Pri ideálnej rýchlosti internetu klient dostane aktualizáciu hry presne každých 33 ms (30 za sekundu):

Vytvorenie webovej hry pre viacerých hráčov v žánri .io
Žiaľ, nič nie je dokonalé. Reálnejší obrázok by bol:
Vytvorenie webovej hry pre viacerých hráčov v žánri .io
Naivná implementácia je v podstate najhorším prípadom, pokiaľ ide o latenciu. Ak je aktualizácia hry prijatá s oneskorením 50 ms, potom klient je spomalený o ďalších 50 ms, pretože stále vykresľuje stav hry z predchádzajúcej aktualizácie. Viete si predstaviť, aké je to pre hráča nepohodlné: kvôli svojvoľným spomaleniam bude hra pôsobiť trhane a nestabilne.

7.2 Vylepšený stav klienta

Naivnú implementáciu vylepšíme. Po prvé, používame oneskorenie vykresľovania o 100 ms. To znamená, že "aktuálny" stav klienta bude vždy 100 ms za stavom hry na serveri. Napríklad, ak je čas servera 150, potom klient vykreslí stav, v ktorom bol server v danom čase 50:

Vytvorenie webovej hry pre viacerých hráčov v žánri .io
To nám dáva 100 ms vyrovnávaciu pamäť, aby sme prežili nepredvídateľné načasovanie aktualizácií hry:

Vytvorenie webovej hry pre viacerých hráčov v žánri .io
Cena za to bude trvalá vstupné oneskorenie o 100 ms. Ide o menšiu obetu pre plynulé hranie – väčšina hráčov (najmä tých príležitostných) si toto oneskorenie ani nevšimne. Pre ľudí je oveľa jednoduchšie prispôsobiť sa konštantnej latencii 100 ms, ako hrať s nepredvídateľnou latenciou.

Môžeme použiť inú techniku ​​tzv "prognóza na strane klienta", ktorý robí dobrú prácu pri znižovaní vnímanej latencie, ale o tom sa v tomto príspevku nebude diskutovať.

Ďalšie vylepšenie, ktoré používame, je lineárna interpolácia. Kvôli oneskoreniu vykresľovania sme zvyčajne v klientovi aspoň o jednu aktualizáciu pred aktuálnym časom. Pri volaní getCurrentState(), môžeme splniť lineárna interpolácia medzi aktualizáciami hry bezprostredne pred a po aktuálnom čase v klientovi:

Vytvorenie webovej hry pre viacerých hráčov v žánri .io
Toto rieši problém so snímkovou frekvenciou: teraz môžeme vykresliť jedinečné snímky pri akejkoľvek snímkovej frekvencii, ktorú potrebujeme!

7.3 Implementácia vylepšeného stavu klienta

Príklad implementácie v src/client/state.js používa oneskorenie vykresľovania aj lineárnu interpoláciu, ale to netrvá dlho. Rozdeľme kód na dve časti. Tu je prvý:

state.js, časť 1

const RENDER_DELAY = 100;

const gameUpdates = [];
let gameStart = 0;
let firstServerTimestamp = 0;

export function initState() {
  gameStart = 0;
  firstServerTimestamp = 0;
}

export function processGameUpdate(update) {
  if (!firstServerTimestamp) {
    firstServerTimestamp = update.t;
    gameStart = Date.now();
  }
  gameUpdates.push(update);

  // Keep only one game update before the current server time
  const base = getBaseUpdate();
  if (base > 0) {
    gameUpdates.splice(0, base);
  }
}

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

// Returns the index of the base update, the first game update before
// current server time, or -1 if N/A.
function getBaseUpdate() {
  const serverTime = currentServerTime();
  for (let i = gameUpdates.length - 1; i >= 0; i--) {
    if (gameUpdates[i].t <= serverTime) {
      return i;
    }
  }
  return -1;
}

Prvá vec, ktorú musíte urobiť, je zistiť, čo to robí currentServerTime(). Ako sme už videli, každá aktualizácia hry obsahuje časovú pečiatku servera. Chceme použiť latenciu vykresľovania na vykreslenie obrázka 100 ms za serverom, ale nikdy nebudeme vedieť aktuálny čas na serveri, pretože nevieme, ako dlho trvalo, kým sa k nám niektorá z aktualizácií dostala. Internet je nepredvídateľný a jeho rýchlosť sa môže značne líšiť!

Aby sme tento problém obišli, môžeme použiť rozumnú aproximáciu: my predstierajme, že prvá aktualizácia prišla okamžite. Ak by to bola pravda, potom by sme poznali čas servera v danom okamihu! Ukladáme časovú pečiatku servera firstServerTimestamp a zachráňte naše miestna (klient) časová pečiatka v rovnakom momente v gameStart.

Oh, počkaj chvíľu. Nemal by byť čas na serveri = čas na klientovi? Prečo rozlišujeme medzi „časovou pečiatkou servera“ a „časovou pečiatkou klienta“? Toto je skvelá otázka! Ukazuje sa, že nejde o to isté. Date.now() vráti rôzne časové pečiatky na klientovi a serveri a to závisí od miestnych faktorov pre tieto počítače. Nikdy nepredpokladajte, že časové pečiatky budú rovnaké na všetkých počítačoch.

Teraz chápeme, čo to robí currentServerTime(): vracia sa serverová časová pečiatka aktuálneho času vykresľovania. Inými slovami, toto je aktuálny čas servera (firstServerTimestamp <+ (Date.now() - gameStart)) mínus oneskorenie vykresľovania (RENDER_DELAY).

Teraz sa pozrime, ako riešime aktualizácie hier. Po prijatí aktualizácie zo servera sa zavolá processGameUpdate()a novú aktualizáciu uložíme do poľa gameUpdates. Potom, aby sme skontrolovali využitie pamäte, odstránime všetky staré aktualizácie základná aktualizáciapretože ich už nepotrebujeme.

Čo je to „základná aktualizácia“? Toto prvú aktualizáciu nájdeme posunutím dozadu od aktuálneho serverového času. Pamätáte si tento diagram?

Vytvorenie webovej hry pre viacerých hráčov v žánri .io
Aktualizácia hry priamo naľavo od "Client Render Time" je základná aktualizácia.

Na čo slúži základná aktualizácia? Prečo môžeme aktualizácie presunúť na základňu? Aby sme to pochopili, poďme konečne pozrime sa na implementáciu getCurrentState():

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

Riešime tri prípady:

  1. base < 0 znamená, že do aktuálneho času vykresľovania nie sú k dispozícii žiadne aktualizácie (pozri implementáciu vyššie getBaseUpdate()). To sa môže stať hneď na začiatku hry kvôli oneskoreniu vykresľovania. V tomto prípade používame najnovšiu prijatú aktualizáciu.
  2. base je najnovšia aktualizácia, ktorú máme. Môže k tomu dôjsť v dôsledku latencie siete alebo slabého internetového pripojenia. Aj v tomto prípade používame najnovšiu aktualizáciu, ktorú máme.
  3. Máme aktualizáciu pred aj po aktuálnom čase vykresľovania, takže môžeme interpolovať!

Všetko, čo zostalo state.js je implementácia lineárnej interpolácie, ktorá je jednoduchá (ale nudná) matematika. Ak to chcete preskúmať sami, otvorte state.js na GitHub.

Časť 2. Backend server

V tejto časti sa pozrieme na backend Node.js, ktorý riadi náš príklad hry .io.

1. Vstupný bod servera

Na správu webového servera budeme používať populárny webový framework Node.js tzv expresné. Bude nakonfigurovaný súborom vstupných bodov nášho servera src/server/server.js:

server.js, časť 1

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

// Setup an Express server
const app = express();
app.use(express.static('public'));

if (process.env.NODE_ENV === 'development') {
  // Setup Webpack for development
  const compiler = webpack(webpackConfig);
  app.use(webpackDevMiddleware(compiler));
} else {
  // Static serve the dist/ folder in production
  app.use(express.static('dist'));
}

// Listen on port
const port = process.env.PORT || 3000;
const server = app.listen(port);
console.log(`Server listening on port ${port}`);

Pamätáte si, že v prvej časti sme diskutovali o Webpacku? Tu použijeme naše konfigurácie Webpack. Budeme ich aplikovať dvoma spôsobmi:

  • použitie webpack-dev-middleware na automatické prebudovanie našich vývojových balíkov, príp
  • Statický prenos priečinka dist/, do ktorého Webpack zapíše naše súbory po vytvorení produkcie.

Ďalšia dôležitá úloha server.js pozostáva z nastavenia servera socket.ioktorý sa jednoducho pripojí k expresnému serveru:

server.js, časť 2

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

// Setup Express
// ...
const server = app.listen(port);
console.log(`Server listening on port ${port}`);

// Setup socket.io
const io = socketio(server);

// Listen for socket.io connections
io.on('connection', socket => {
  console.log('Player connected!', socket.id);

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

Po úspešnom nadviazaní spojenia soket.io so serverom nakonfigurujeme obslužné rutiny udalostí pre nový soket. Obslužné programy udalostí spracúvajú správy prijaté od klientov delegovaním na jediný objekt game:

server.js, časť 3

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

// ...

// Setup the Game
const game = new Game();

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

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

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

Vytvárame hru .io, takže budeme potrebovať iba jednu kópiu Game („Hra“) – všetci hráči hrajú v rovnakej aréne! V ďalšej časti uvidíme, ako táto trieda funguje Game.

2. Herné servery

Trieda Game obsahuje najdôležitejšiu logiku na strane servera. Má dve hlavné úlohy: manažment hráčov и herná simulácia.

Začnime prvou úlohou – manažovaním hráčov.

game.js, časť 1

const Constants = require('../shared/constants');
const Player = require('./player');

class Game {
  constructor() {
    this.sockets = {};
    this.players = {};
    this.bullets = [];
    this.lastUpdateTime = Date.now();
    this.shouldSendUpdate = false;
    setInterval(this.update.bind(this), 1000 / 60);
  }

  addPlayer(socket, username) {
    this.sockets[socket.id] = socket;

    // Generate a position to start this player at.
    const x = Constants.MAP_SIZE * (0.25 + Math.random() * 0.5);
    const y = Constants.MAP_SIZE * (0.25 + Math.random() * 0.5);
    this.players[socket.id] = new Player(socket.id, username, x, y);
  }

  removePlayer(socket) {
    delete this.sockets[socket.id];
    delete this.players[socket.id];
  }

  handleInput(socket, dir) {
    if (this.players[socket.id]) {
      this.players[socket.id].setDirection(dir);
    }
  }

  // ...
}

V tejto hre identifikujeme hráčov podľa poľa id ich socket socket.io (ak ste zmätení, vráťte sa na server.js). Samotný Socket.io priraďuje každej zásuvke jedinečný id, takže si s tým nemusíme robiť starosti. zavolám mu ID hráča.

S ohľadom na to poďme preskúmať premenné inštancie v triede Game:

  • sockets je objekt, ktorý spája ID prehrávača so zásuvkou, ktorá je spojená s prehrávačom. Umožňuje nám v priebehu času pristupovať k zásuvkám podľa ich ID hráčov.
  • players je objekt, ktorý spája ID hráča s kódom>Objekt hráča

bullets je súbor objektov Bulletbez konkrétnej objednávky.
lastUpdateTime - Toto je časová pečiatka poslednej aktualizácie hry. Čoskoro uvidíme, ako sa to využije.
shouldSendUpdate je pomocná premenná. Čoskoro sa dočkáme aj jeho využitia.
metódy addPlayer(), removePlayer() и handleInput() netreba vysvetľovať, používajú sa v server.js. Ak sa potrebujete osviežiť, vráťte sa trochu vyššie.

Posledný riadok constructor() naštartuje sa cyklus aktualizácie hry (s frekvenciou 60 aktualizácií/s):

game.js, časť 2

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

class Game {
  // ...

  update() {
    // Calculate time elapsed
    const now = Date.now();
    const dt = (now - this.lastUpdateTime) / 1000;
    this.lastUpdateTime = now;

    // Update each bullet
    const bulletsToRemove = [];
    this.bullets.forEach(bullet => {
      if (bullet.update(dt)) {
        // Destroy this bullet
        bulletsToRemove.push(bullet);
      }
    });
    this.bullets = this.bullets.filter(
      bullet => !bulletsToRemove.includes(bullet),
    );

    // Update each player
    Object.keys(this.sockets).forEach(playerID => {
      const player = this.players[playerID];
      const newBullet = player.update(dt);
      if (newBullet) {
        this.bullets.push(newBullet);
      }
    });

    // Apply collisions, give players score for hitting bullets
    const destroyedBullets = applyCollisions(
      Object.values(this.players),
      this.bullets,
    );
    destroyedBullets.forEach(b => {
      if (this.players[b.parentID]) {
        this.players[b.parentID].onDealtDamage();
      }
    });
    this.bullets = this.bullets.filter(
      bullet => !destroyedBullets.includes(bullet),
    );

    // Check if any players are dead
    Object.keys(this.sockets).forEach(playerID => {
      const socket = this.sockets[playerID];
      const player = this.players[playerID];
      if (player.hp <= 0) {
        socket.emit(Constants.MSG_TYPES.GAME_OVER);
        this.removePlayer(socket);
      }
    });

    // Send a game update to each player every other time
    if (this.shouldSendUpdate) {
      const leaderboard = this.getLeaderboard();
      Object.keys(this.sockets).forEach(playerID => {
        const socket = this.sockets[playerID];
        const player = this.players[playerID];
        socket.emit(
          Constants.MSG_TYPES.GAME_UPDATE,
          this.createUpdate(player, leaderboard),
        );
      });
      this.shouldSendUpdate = false;
    } else {
      this.shouldSendUpdate = true;
    }
  }

  // ...
}

metóda update() obsahuje pravdepodobne najdôležitejšiu časť logiky na strane servera. Uveďme si všetko, čo robí, v poradí:

  1. Vypočíta koľko je hodín dt je to od poslednej update().
  2. Osvieži každý projektil a v prípade potreby ho zničí. Implementácie tejto funkcionality uvidíme neskôr. Zatiaľ nám to stačí vedieť bullet.update() sa vracia true, ak musí byť projektil zničený (išiel mimo arénu).
  3. Aktualizuje každého hráča a v prípade potreby vytvorí projektil. Túto implementáciu uvidíme neskôr - player.update() môže vrátiť predmet Bullet.
  4. Kontroluje kolízie medzi projektilmi a hráčmi, ktorí používajú applyCollisions(), ktorý vracia rad projektilov, ktoré zasiahnu hráčov. Za každý vrátený projektil zvyšujeme skóre hráča, ktorý ho vystrelil (pomocou player.onDealtDamage()) a potom odstráňte projektil z poľa bullets.
  5. Upozorní a zničí všetkých zabitých hráčov.
  6. Odošle aktualizáciu hry všetkým hráčom každú sekundu časy pri volaní update(). Pomocná premenná uvedená vyššie nám to pomáha sledovať shouldSendUpdate... Pretože update() volaných 60-krát/s, aktualizácie hry posielame 30-krát/s. teda frekvencia hodín server je 30 hodinových cyklov/s (o frekvencii hodín sme hovorili v prvej časti).

Prečo posielať iba aktualizácie hier časom ? Ak chcete uložiť kanál. 30 aktualizácií hry za sekundu je veľa!

Prečo potom nezavolať? update() 30 krát za sekundu? Na zlepšenie simulácie hry. Čím častejšie je tzv update(), tým presnejšia bude simulácia hry. Nenechajte sa však príliš uniesť množstvom výziev update(), pretože ide o výpočtovo nákladnú úlohu – 60 za sekundu je celkom dosť.

Zvyšok triedy Game pozostáva z pomocných metód používaných v update():

game.js, časť 3

class Game {
  // ...

  getLeaderboard() {
    return Object.values(this.players)
      .sort((p1, p2) => p2.score - p1.score)
      .slice(0, 5)
      .map(p => ({ username: p.username, score: Math.round(p.score) }));
  }

  createUpdate(player, leaderboard) {
    const nearbyPlayers = Object.values(this.players).filter(
      p => p !== player && p.distanceTo(player) <= Constants.MAP_SIZE / 2,
    );
    const nearbyBullets = this.bullets.filter(
      b => b.distanceTo(player) <= Constants.MAP_SIZE / 2,
    );

    return {
      t: Date.now(),
      me: player.serializeForUpdate(),
      others: nearbyPlayers.map(p => p.serializeForUpdate()),
      bullets: nearbyBullets.map(b => b.serializeForUpdate()),
      leaderboard,
    };
  }
}

getLeaderboard() Je to celkom jednoduché – zoradí hráčov podľa skóre, vyberie prvých päť a pre každého vráti používateľské meno a skóre.

createUpdate() používaný v update() vytvárať aktualizácie hry, ktoré sú distribuované hráčom. Jeho hlavnou úlohou je volať metódy serializeForUpdate(), implementované pre triedy Player и Bullet. Upozorňujeme, že prenáša údaje iba každému hráčovi najbližšie hráči a projektily - nie je potrebné prenášať informácie o herných objektoch umiestnených ďaleko od hráča!

3. Herné objekty na serveri

V našej hre sú projektily a hráči v skutočnosti veľmi podobní: sú to abstraktné okrúhle pohyblivé herné objekty. Aby sme využili túto podobnosť medzi hráčmi a projektilmi, začnime implementáciou základnej triedy 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,
    };
  }
}

Nedeje sa tu nič zložité. Táto trieda bude dobrým východiskovým bodom pre expanziu. Pozrime sa, ako trieda Bullet používa 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 veľmi krátky! Pridali sme k Object iba nasledujúce rozšírenia:

  • Použitie balíka shortid pre náhodné generovanie id projektil.
  • Pridanie poľa parentID, aby ste mohli sledovať hráča, ktorý vytvoril tento projektil.
  • Pridanie návratovej hodnoty k update(), čo sa rovná true, ak je projektil mimo arény (pamätáte si, že sme o tom hovorili v minulej časti?).

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

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

Hráči sú zložitejšie ako projektily, takže táto trieda by mala obsahovať niekoľko ďalších polí. Jeho metóda update() robí viac práce, najmä vracia novovytvorenú strelu, ak už žiadna nezostala fireCooldown (pamätáte si, že sme o tom hovorili v predchádzajúcej časti?). Rozširuje aj metódu serializeForUpdate(), pretože do aktualizácie hry musíme zahrnúť ďalšie polia pre hráča.

Dostupnosť základnej triedy Object - dôležitý krok, ako sa vyhnúť opakovaniu kódu. Napríklad bez triedy Object každý herný objekt musí mať rovnakú implementáciu distanceTo()a kopírovanie a vkladanie všetkých týchto implementácií do viacerých súborov by bolo nočnou morou. Toto je obzvlášť dôležité pri veľkých projektoch, kedy sa počet rozširujúcich Object triedy rastú.

4. Detekcia kolízie

Jediné, čo nám ostáva, je rozpoznať, kedy projektily zasiahli hráčov! Zapamätajte si tento útržok kódu z metódy update() v triede 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),
    );

    // ...
  }
}

Musíme implementovať metódu applyCollisions(), ktorý vráti všetky projektily, ktoré zasiahnu hráčov. Našťastie to nie je také ťažké urobiť, pretože

  • Všetky kolidujúce objekty sú kruhy a toto je najjednoduchší tvar na implementáciu detekcie kolízie.
  • Už máme metódu distanceTo(), ktorý sme na hodine implementovali v predchádzajúcej časti Object.

Takto vyzerá naša implementácia detekcie kolízií:

kolízie.js

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

// Returns an array of bullets to be destroyed.
function applyCollisions(players, bullets) {
  const destroyedBullets = [];
  for (let i = 0; i < bullets.length; i++) {
    // Look for a player (who didn't create the bullet) to collide each bullet with.
    // As soon as we find one, break out of the loop to prevent double counting a bullet.
    for (let j = 0; j < players.length; j++) {
      const bullet = bullets[i];
      const player = players[j];
      if (
        bullet.parentID !== player.id &&
        player.distanceTo(bullet) <= Constants.PLAYER_RADIUS + Constants.BULLET_RADIUS
      ) {
        destroyedBullets.push(bullet);
        player.takeBulletDamage();
        break;
      }
    }
  }
  return destroyedBullets;
}

Táto jednoduchá detekcia kolízií je založená na tom, že dva kruhy sa zrazia, ak vzdialenosť medzi ich stredmi je menšia ako súčet ich polomerov. Tu je prípad, keď sa vzdialenosť medzi stredmi dvoch kruhov presne rovná súčtu ich polomerov:

Vytvorenie webovej hry pre viacerých hráčov v žánri .io
Tu musíte venovať veľkú pozornosť niekoľkým ďalším aspektom:

  • Projektil nesmie zasiahnuť hráča, ktorý ho vytvoril. Dá sa to dosiahnuť porovnávaním bullet.parentID с player.id.
  • Projektil by mal zasiahnuť iba raz v extrémnom prípade zasiahnutia viacerých hráčov súčasne. Tento problém vyriešime pomocou operátora break: Keď sa nájde hráč, ktorý sa zrazil s projektilom, prestaneme hľadať a prejdeme na ďalší projektil.

Koniec

To je všetko! Pokryli sme všetko, čo potrebujete vedieť na vytvorenie webovej hry .io. Čo bude ďalej? Zostavte si svoju vlastnú .io hru!

Všetky príklady kódu sú open source a sú zverejnené GitHub.

Zdroj: hab.com

Pridať komentár