Vytvoření webové hry .io pro více hráčů

Vytvoření webové hry .io pro více hráčů
Vydáno v roce 2015 Agar.io se stal předchůdcem nového žánru hry .iokterá od té doby roste na popularitě. Osobně jsem zažil nárůst popularity .io her: za poslední tři roky ano vytvořil a prodal dvě hry tohoto žánru..

V případě, že jste o těchto hrách ještě nikdy neslyšeli, jedná se o bezplatné webové hry pro více hráčů, které se snadno hrají (nevyžaduje se žádný účet). Obvykle čelí mnoha soupeřům ve stejné aréně. Další známé .io hry: Slither.io и Diep.io.

V tomto příspěvku prozkoumáme jak vytvořit hru .io od nuly. K tomu vám postačí pouze znalost Javascriptu: musíte rozumět věcem, jako je syntaxe ES6, klíčové slovo this и Promises. I když vaše znalost Javascriptu není dokonalá, většině příspěvku stále rozumíte.

Příklad hry .io

Pro pomoc s učením se odkážeme na Příklad hry .io. Zkuste si to zahrát!

Vytvoření webové hry .io pro více hráčů
Hra je celkem jednoduchá: ovládáte loď v aréně, kde jsou další hráči. Vaše loď automaticky vystřelí projektily a vy se snažíte zasáhnout ostatní hráče a přitom se vyhnout jejich projektilům.

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

doporučit stáhnout zdrojový kód příklad hry, abyste mě mohli sledovat.

Příklad používá následující:

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

Zde je, jak vypadá struktura adresářů projektu:

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

veřejnost/

Vše ve složce public/ bude staticky odeslán serverem. V public/assets/ obsahuje obrázky používané naším projektem.

src /

Veškerý zdrojový kód je ve složce src/. Tituly client/ и server/ mluvit za sebe a shared/ obsahuje soubor konstant, který je importován klientem i serverem.

2. Nastavení sestav/projektu

Jak bylo uvedeno výše, k sestavení projektu používáme správce modulů. Webpack. Pojďme se podívat na naši konfiguraci Webpacku:

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ší řádky jsou zde:

  • src/client/index.js je vstupním bodem klienta Javascript (JS). Webpack se spustí odtud a bude rekurzivně vyhledávat další importované soubory.
  • Výstupní JS našeho sestavení Webpacku bude umístěn v adresáři dist/. Tento soubor budu nazývat naším js balíček.
  • Používáme Babela zejména konfiguraci @babel/preset-env k transpilaci našeho JS kódu pro starší prohlížeče.
  • Používáme plugin k extrahování všech CSS, na které odkazují soubory JS, a jejich spojení na jednom místě. Budu mu říkat náš css balíček.

Možná jste si všimli podivných názvů souborů balíčků '[name].[contenthash].ext'. Obsahují náhrady názvu souboru Webový balíček: [name] bude nahrazeno názvem vstupního bodu (v našem případě toto game) a [contenthash] bude nahrazeno hashem obsahu souboru. Děláme to optimalizovat projekt pro hashování - můžete prohlížečům přikázat, aby naše balíčky JS ukládaly do mezipaměti na dobu neurčitou, protože pokud se balíček změní, změní se i jeho název souboru (Změny contenthash). Konečným výsledkem bude název souboru pohledu game.dbeee76e91a97d0c7207.js.

Soubor webpack.common.js je základní konfigurační soubor, který importujeme do vývojových a hotových konfigurací projektu. Zde je příklad konfigurace vývoje:

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.jsa přepne na webpack.prod.jsoptimalizovat velikosti balíčků při nasazení do výroby.

Místní nastavení

Doporučuji nainstalovat projekt na místní počítač, abyste mohli postupovat podle kroků uvedených v tomto příspěvku. Nastavení je jednoduché: nejprve musí být nainstalován systém Uzel и NPM. Dále musíte udělat

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

a jste připraveni vyrazit! Chcete-li spustit vývojový server, stačí spustit

$ npm run develop

a přejděte do webového prohlížeče localhost: 3000. Vývojový server automaticky znovu sestaví balíčky JS a CSS, jak se změní kód – stačí obnovit stránku, abyste viděli všechny změny!

3. Klientské vstupní body

Pojďme k samotnému kódu hry. Nejprve potřebujeme stránku index.html, při návštěvě stránky ji prohlížeč načte jako první. Naše stránka bude velmi jednoduchá:

index.html

Příklad hry .io  HRÁT SI

Tento příklad kódu byl kvůli přehlednosti mírně zjednodušen a totéž udělám s mnoha dalšími příklady příspěvků. Celý kód lze vždy zobrazit na GitHub.

My máme:

  • prvek plátna HTML5 (<canvas>), který použijeme k vykreslení hry.
  • <link> přidat náš balíček CSS.
  • <script> přidat náš balíček Javascript.
  • Hlavní menu s uživatelským jménem <input> a tlačítko PLAY (<button>).

Po načtení domovské stránky začne prohlížeč spouštět Javascript kód, počínaje vstupním bodem JS souboru: 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 znít složitě, ale tady se toho moc neděje:

  1. Import několika dalších souborů JS.
  2. Import CSS (aby je Webpack věděl, že je má zahrnout do našeho balíčku CSS).
  3. Spusťte connect() navázat spojení se serverem a spustit downloadAssets() ke stažení obrázků potřebných k vykreslení hry.
  4. Po dokončení fáze 3 zobrazí se hlavní nabídka (playMenu).
  5. Nastavení ovladače pro stisk tlačítka "PLAY". Po stisknutí tlačítka kód inicializuje hru a sdělí serveru, že jsme připraveni hrát.

Hlavní "maso" naší klient-server logiky je v těch souborech, které byly importovány souborem index.js. Nyní je všechny zvážíme v pořadí.

4. Výměna údajů o zákaznících

V této hře používáme ke komunikaci se serverem známou knihovnu socket.io. Socket.io má nativní podporu WebSockets, které se dobře hodí pro obousměrnou komunikaci: můžeme odesílat zprávy na server и server nám může posílat zprávy na stejném připojení.

Budeme mít jeden soubor src/client/networking.jskdo se bude starat všemi 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('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 byl také mírně zkrácen pro přehlednost.

V tomto souboru jsou tři hlavní akce:

  • Snažíme se připojit k serveru. connectedPromise povoleno pouze tehdy, když jsme navázali spojení.
  • Pokud je připojení úspěšné, zaregistrujeme funkce zpětného volání (processGameUpdate() и onGameOver()) pro zprávy, které můžeme přijímat ze serveru.
  • Vyvážíme play() и updateDirection()aby je mohly používat jiné soubory.

5. Klientské vykreslování

Je čas zobrazit obrázek na obrazovce!

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

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

Implementace řízení zdrojů není tak těžké! Hlavní myšlenkou je uložení předmětu assets, který naváže klíč názvu souboru na hodnotu objektu Image. Když je zdroj načten, uložíme jej do objektu assets pro rychlý přístup v budoucnu. Kdy bude povoleno stahování každého jednotlivého zdroje (tj. vše zdroje), umožňujeme downloadPromise.

Po stažení zdrojů můžete začít vykreslovat. Jak již bylo řečeno, ke kreslení na webové stránce používáme HTML5 plátno (<canvas>). Naše hra je docela jednoduchá, takže stačí nakreslit následující:

  1. pozadí
  2. Hráčská loď
  3. Ostatní hráči ve hře
  4. Mušle

Zde jsou důležité úryvky src/client/render.js, které vykreslí přesně čtyři výše uvedené položky:

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 také zkrácen pro přehlednost.

render() je hlavní funkcí tohoto souboru. startRendering() и stopRendering() ovládat aktivaci renderovací smyčky při 60 FPS.

Konkrétní implementace jednotlivých pomocných funkcí vykreslování (např. renderBullet()) nejsou tak důležité, ale zde 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(), který byl dříve viděn v asset.js!

Pokud máte zájem dozvědět se o dalších pomocníkech při vykreslování, přečtěte si zbytek. src/client/render.js.

6. Vstup klienta

Je čas udělat hru hratelné! Schéma ovládání bude velmi jednoduché: pro změnu směru pohybu můžete použít myš (na počítači) nebo se dotknout obrazovky (na mobilním zařízení). Abychom to mohli implementovat, zaregistrujeme se Posluchači událostí pro události Myš a dotyk.
O tohle všechno 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() и onTouchInput() jsou posluchači událostí, kteří volají updateDirection() (z networking.js), když nastane vstupní událost (například při pohybu myši). updateDirection() zpracovává zasílání zpráv se serverem, který zpracovává vstupní událost a podle toho aktualizuje stav hry.

7. Stav klienta

Tato část je nejobtížnější v první části příspěvku. Nenechte se odradit, pokud tomu při prvním čtení nerozumíte! Můžete ho dokonce přeskočit a vrátit se k němu později.

Poslední dílek skládačky potřebný k dokončení kódu klient/server je stát. Pamatujete si fragment kódu ze sekce Klientské vykreslování?

render.js

import { getCurrentState } from './state';

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

  // Do the rendering
  // ...
}

getCurrentState() by nám měl být schopen poskytnout aktuální stav hry v klientovi v kterémkoli okamžiku na základě aktualizací přijatých ze serveru. Zde je příklad aktualizace hry, kterou může server odeslat:

{
  "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á aktualizace hry obsahuje pět stejných polí:

  • t: Časové razítko serveru udávající, kdy byla tato aktualizace vytvořena.
  • me: Informace o hráči, který obdrží tuto aktualizaci.
  • další: Řada informací o ostatních hráčích účastnících se stejné hry.
  • kulky: řada informací o projektilech ve hře.
  • leaderboard: Aktuální data žebříčku. V tomto příspěvku je nebudeme uvažovat.

7.1 Naivní stav klienta

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

naive-state.js

let lastGameUpdate = null;

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

export function getCurrentState() {
  return lastGameUpdate;
}

Pěkné a jasné! Ale kdyby to bylo tak jednoduché. Jedním z důvodů, proč je tato implementace problematická: omezuje snímkovou frekvenci vykreslování na taktovací frekvenci serveru.

Snímková frekvence: počet snímků (tj. volání render()) za sekundu nebo FPS. Hry se obvykle snaží dosáhnout alespoň 60 FPS.

Zaškrtávací sazba: Frekvence, s jakou server odesílá aktualizace her klientům. Často je nižší než snímková frekvence. V naší hře běží server s frekvencí 30 cyklů za sekundu.

Pokud pouze vykreslíme poslední aktualizaci hry, pak FPS v podstatě nikdy nepřekročí 30, protože nikdy nedostaneme ze serveru více než 30 aktualizací za sekundu. I když voláme render() 60krát za sekundu, pak polovina těchto hovorů pouze překreslí to samé, v podstatě nic nedělá. Dalším problémem naivní implementace je, že ano náchylné ke zpožděním. Při ideální rychlosti internetu klient obdrží aktualizaci hry přesně každých 33 ms (30 za sekundu):

Vytvoření webové hry .io pro více hráčů
Bohužel nic není dokonalé. Realističtější obrázek by byl:
Vytvoření webové hry .io pro více hráčů
Naivní implementace je prakticky nejhorší případ, pokud jde o latenci. Pokud je aktualizace hry přijata se zpožděním 50 ms, pak stánky klientů dalších 50 ms, protože stále vykresluje stav hry z předchozí aktualizace. Dokážete si představit, jak je to pro hráče nepříjemné: svévolné brzdění způsobí, že hra bude trhavá a nestabilní.

7.2 Vylepšený stav klienta

Uděláme několik vylepšení naivní implementace. Nejprve používáme zpoždění vykreslování po dobu 100 ms. To znamená, že "aktuální" stav klienta bude vždy zaostávat za stavem hry na serveru o 100 ms. Například, pokud je čas na serveru 150, pak klient vykreslí stav, ve kterém se server v danou chvíli nacházel 50:

Vytvoření webové hry .io pro více hráčů
To nám dává vyrovnávací paměť 100 ms, abychom přežili nepředvídatelné časy aktualizací hry:

Vytvoření webové hry .io pro více hráčů
Výplata za to bude trvalá vstupní zpoždění po dobu 100 ms. To je menší oběť pro plynulé hraní – většina hráčů (zejména příležitostných hráčů) si toto zpoždění ani nevšimne. Pro lidi je mnohem snazší přizpůsobit se konstantní latenci 100 ms, než hrát s nepředvídatelnou latencí.

Můžeme použít i jinou techniku ​​tzv predikce na straně klienta, který odvádí dobrou práci při snižování vnímané latence, ale tím se tento příspěvek nebude zabývat.

Další vylepšení, které používáme, je lineární interpolace. Kvůli zpoždění vykreslování jsme obvykle alespoň o jednu aktualizaci před aktuálním časem v klientovi. Při zavolání getCurrentState(), můžeme provést lineární interpolace mezi aktualizacemi hry těsně před a po aktuálním čase v klientovi:

Vytvoření webové hry .io pro více hráčů
Tím je vyřešen problém s frekvencí snímků: nyní můžeme vykreslovat jedinečné snímky s jakoukoli snímkovou frekvencí, kterou chceme!

7.3 Implementace rozšířeného stavu klienta

Příklad implementace v src/client/state.js používá zpoždění vykreslování i lineární interpolaci, ale ne na dlouho. Rozdělme kód na dvě části. Tady 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);

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

Prvním krokem je zjistit co currentServerTime(). Jak jsme viděli dříve, každá aktualizace hry obsahuje časové razítko serveru. Chceme použít latenci vykreslování k vykreslení obrázku 100 ms za serverem, ale nikdy nebudeme znát aktuální čas na serveru, protože nemůžeme vědět, jak dlouho trvalo, než se k nám některá z aktualizací dostala. Internet je nepředvídatelný a jeho rychlost se může značně lišit!

Abychom tento problém obešli, můžeme použít rozumnou aproximaci: my předstírat, že první aktualizace dorazila okamžitě. Pokud by to byla pravda, pak bychom znali čas serveru v tomto konkrétním okamžiku! Ukládáme časové razítko serveru firstServerTimestamp a udržet naše místní časové razítko (klienta) ve stejný okamžik v gameStart.

OH Počkej. Nemělo by to být čas serveru = čas klienta? Proč rozlišujeme „časové razítko serveru“ a „časové razítko klienta“? To je skvělá otázka! Ukazuje se, že nejsou totéž. Date.now() vrátí různá časová razítka na klientovi a serveru a závisí na faktorech místních těchto počítačů. Nikdy nepředpokládejte, že časová razítka budou na všech počítačích stejná.

Nyní chápeme, co dělá currentServerTime(): vrací se časové razítko serveru aktuálního času vykreslování. Jinými slovy, toto je aktuální čas serveru (firstServerTimestamp <+ (Date.now() - gameStart)) mínus zpoždění vykreslení (RENDER_DELAY).

Nyní se pojďme podívat na to, jak nakládáme s aktualizacemi her. Po přijetí z aktualizačního serveru se zavolá processGameUpdate()a uložíme novou aktualizaci do pole gameUpdates. Poté, abychom zkontrolovali využití paměti, odstraníme všechny staré aktualizace základní aktualizaceprotože už je nepotřebujeme.

Co je to „základní aktualizace“? Tento první aktualizaci najdeme posunutím zpět od aktuálního času serveru. Pamatujete si tento diagram?

Vytvoření webové hry .io pro více hráčů
Aktualizace hry přímo nalevo od "Client Render Time" je základní aktualizací.

K čemu slouží základní aktualizace? Proč můžeme pustit aktualizace na základní úroveň? Abychom to zjistili, pojďme konečně zvážit 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),
    };
  }
}

Řešíme tři případy:

  1. base < 0 znamená, že do aktuálního času vykreslování nejsou žádné aktualizace (viz výše implementace getBaseUpdate()). To se může stát hned na začátku hry kvůli zpoždění při vykreslování. V tomto případě používáme nejnovější přijatou aktualizaci.
  2. base je nejnovější aktualizace, kterou máme. Může to být způsobeno zpožděním sítě nebo špatným připojením k internetu. V tomto případě také používáme nejnovější aktualizaci, kterou máme.
  3. Máme aktualizaci před i po aktuální době vykreslování, takže můžeme interpolovat!

Vše, co zbylo state.js je implementace lineární interpolace, která je jednoduchá (ale nudná) matematika. Pokud to chcete prozkoumat sami, otevřete state.js na GitHub.

Část 2. Backend server

V tomto díle se podíváme na backend Node.js, který řídí naše Příklad hry .io.

1. Vstupní bod serveru

Ke správě webového serveru použijeme populární webový framework pro Node.js tzv Expresní. Bude nakonfigurován naším souborem vstupních 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');

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

Pamatujete si, že v první části jsme diskutovali o Webpacku? Zde použijeme naše konfigurace Webpacku. Budeme je používat dvěma způsoby:

  • Chcete-li použít webpack-dev-middleware k automatické přestavbě našich vývojových balíčků, popř
  • staticky přenosovou složku dist/, do kterého Webpack zapíše naše soubory po produkčním sestavení.

Další důležitý úkol server.js je nastavit server socket.iokterý se právě připojuje k Express serveru:

server.js část 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 úspěšném navázání připojení socket.io k serveru nastavíme obslužné rutiny událostí pro nový soket. Obslužné rutiny událostí zpracovávají zprávy přijaté od klientů delegováním na jediný objekt game:

server.js část 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áříme .io hru, takže potřebujeme pouze jednu kopii Game ("Hra") - všichni hráči hrají ve stejné aréně! V další části uvidíme, jak tato třída funguje. Game.

2. Herní servery

Třída Game obsahuje nejdůležitější logiku na straně serveru. Má dva hlavní úkoly: správa hráčů и herní simulace.

Začněme prvním úkolem, správou 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;

    // 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 této hře budeme identifikovat hráče podle pole id jejich socket.io socket (pokud jste zmateni, vraťte se na server.js). Socket.io sám přiřadí každému soketu jedinečný idtakže se o to nemusíme starat. zavolám mu ID hráče.

S ohledem na to pojďme prozkoumat proměnné instance ve třídě Game:

  • sockets je objekt, který váže ID přehrávače k ​​zásuvce, která je spojena s přehrávačem. Umožňuje nám přistupovat k zásuvkám podle jejich ID hráčů v konstantním čase.
  • players je objekt, který váže ID hráče ke kódu>Objekt hráče

bullets je pole objektů Bullet, která nemá jednoznačné pořadí.
lastUpdateTime je časové razítko poslední aktualizace hry. Uvidíme, jak se to bude brzy používat.
shouldSendUpdate je pomocná proměnná. Brzy se také dočkáme jeho využití.
Metody addPlayer(), removePlayer() и handleInput() není třeba vysvětlovat, používají se v server.js. Pokud si potřebujete osvěžit paměť, vraťte se o něco výš.

Poslední řádek constructor() začíná aktualizační cyklus hry (s frekvencí 60 aktualizací/s):

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

  // ...
}

metoda update() obsahuje možná nejdůležitější část logiky na straně serveru. Zde je to, co dělá, v tomto pořadí:

  1. Vypočítá jak dlouho dt uplynulo od posledního update().
  2. Osvěží každý projektil a v případě potřeby je zničí. Implementaci této funkcionality uvidíme později. Zatím nám to stačí vědět bullet.update() se vrací truepokud by měl být projektil zničen (vyšel z arény).
  3. Aktualizuje každého hráče a v případě potřeby vytvoří projektil. Tuto implementaci také uvidíme později - player.update() může vrátit předmět Bullet.
  4. Kontroluje kolize mezi projektily a hráči s applyCollisions(), který vrací řadu projektilů, které zasáhnou hráče. Za každý vrácený projektil zvyšujeme body hráče, který jej vystřelil (pomocí player.onDealtDamage()) a poté vyjměte projektil z pole bullets.
  5. Oznámí a zničí všechny zabité hráče.
  6. Odešle aktualizaci hry všem hráčům každou vteřinu časy při zavolání update(). To nám pomáhá sledovat pomocnou proměnnou uvedenou výše. shouldSendUpdate... Tak jako update() voláno 60krát/s, odesíláme aktualizace hry 30krát/s. Tím pádem, hodinová frekvence takt serveru je 30 hodin/s (o taktovací frekvenci jsme mluvili v první části).

Proč posílat pouze aktualizace her časem ? Chcete-li uložit kanál. 30 aktualizací her za sekundu je hodně!

Proč prostě nezavolat update() 30x za sekundu? Chcete-li zlepšit simulaci hry. Čím častěji tzv update(), tím přesnější bude herní simulace. Nenechte se ale příliš unést množstvím výzev. update(), protože se jedná o výpočetně nákladný úkol – stačí 60 za sekundu.

Zbytek třídy Game sestává 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) <= 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() docela jednoduché - seřadí hráče podle skóre, vezme prvních pět a pro každého vrátí uživatelské jméno a skóre.

createUpdate() použito v update() k vytváření herních aktualizací, které jsou distribuovány hráčům. Jeho hlavním úkolem je volat metody serializeForUpdate()implementováno pro třídy Player и Bullet. Všimněte si, že předává data pouze každému hráči nejbližší hráči a projektily - není potřeba přenášet informace o herních objektech, které jsou daleko od hráče!

3. Herní objekty na serveru

V naší hře jsou si projektily a hráči ve skutečnosti velmi podobní: jsou to abstraktní, kulaté, pohyblivé herní objekty. Abychom využili této podobnosti mezi hráči a projektily, začněme implementací 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,
    };
  }
}

Tady se neděje nic složitého. Tato třída bude dobrým kotevním bodem pro rozšíření. Podívejme se, jak třída Bullet použití 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;
  }
}

uskutečnění Bullet velmi krátké! Přidali jsme k Object pouze následující rozšíření:

  • Pomocí balíčku shortid pro náhodné generování id projektil.
  • Přidání pole parentIDabyste mohli sledovat hráče, který vytvořil tento projektil.
  • Přidání návratové hodnoty k update(), což se rovná truepokud je projektil mimo arénu (pamatujete, že jsme o tom mluvili v minulé sekci?).

Pojďme 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 jsou složitější než projektily, takže v této třídě by mělo být uloženo několik dalších polí. Jeho metoda update() dělá spoustu práce, zejména vrací nově vytvořený projektil, pokud už žádný nezbyl fireCooldown (Pamatujete si, že jsme o tom mluvili v předchozí části?). Také to rozšiřuje metodu serializeForUpdate(), protože do aktualizace hry musíme zahrnout další pole pro hráče.

Mít základní třídu Object - důležitý krok k zamezení opakování kódu. Například žádná třída Object každý herní objekt musí mít stejnou implementaci distanceTo()a kopírování a vkládání všech těchto implementací do více souborů by bylo noční můrou. To je zvláště důležité u velkých projektů.kdy se počet rozšiř Object třídy rostou.

4. Detekce kolize

Jediné, co nám zbývá, je rozpoznat, kdy projektily zasáhly hráče! Zapamatujte si tento kus kódu z metody update() ve třídě 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 metodu implementovat applyCollisions(), který vrací všechny projektily, které zasáhnou hráče. Naštěstí to není tak těžké udělat, protože

  • Všechny kolidující objekty jsou kruhy a toto je nejjednodušší tvar pro implementaci detekce kolize.
  • Už máme metodu distanceTo(), který jsme implementovali v předchozí části ve třídě Object.

Takto vypadá naše implementace detekce kolizí:

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

Tato jednoduchá detekce kolize je založena na tom, že dvě kružnice se srazí, pokud je vzdálenost mezi jejich středy menší než součet jejich poloměrů. Zde je případ, kdy je vzdálenost mezi středy dvou kruhů přesně rovna součtu jejich poloměrů:

Vytvoření webové hry .io pro více hráčů
Zde je třeba zvážit několik dalších aspektů:

  • Projektil nesmí zasáhnout hráče, který jej vytvořil. Toho lze dosáhnout srovnáním bullet.parentID с player.id.
  • Projektil musí zasáhnout pouze jednou v omezujícím případě, že se srazí více hráčů současně. Tento problém vyřešíme pomocí operátora break: jakmile je nalezen hráč, který se střetává s projektilem, zastavíme hledání a přejdeme na další projektil.

Konec

To je vše! Probrali jsme vše, co potřebujete vědět k vytvoření webové hry .io. Co bude dál? Vytvořte si vlastní hru .io!

Veškerý ukázkový kód je open source a je zveřejněn GitHub.

Zdroj: www.habr.com

Přidat komentář