Tworzenie wieloosobowej gry internetowej w stylu .io

Tworzenie wieloosobowej gry internetowej w stylu .io
Wydana w 2015 roku Agar.io stała się prekursorskim tytułem nowego gatunku gier .io, którego popularność od tego czasu znacznie wzrosła. Wzrost popularności gier .io odczułem na własnej skórze: w ciągu ostatnich trzech lat stworzyłem i sprzedałem dwie gry tego gatunku..

Na wszelki wypadek, jeśli nigdy wcześniej nie słyszałeś o takich grach: to darmowe gry wieloosobowe w przeglądarce, w które łatwo się zaangażować (nie wymaga konta). Zazwyczaj na jednej arenie staje do walki wielu przeciwników. Inne znane gry tego gatunku to: Slither.io i Diep.io.

W tym poście przyjrzymy się, jak zbudować grę .io od podstaw.Do tego wystarczy znajomość JavaScript: musisz rozumieć takie rzeczy, jak składnia ES6, słowo kluczowe this i Promises. Nawet jeśli nie znasz JavaScriptu doskonale, nadal będziesz mógł zrozumieć większość posta.

Przykład gry .io

Aby pomóc w nauce, będziemy odwoływać się do przykładu gry .io. Spróbuj w nią zagrać!

Tworzenie wieloosobowej gry internetowej w stylu .io
Gra jest dość prosta: sterujesz statkiem na arenie, gdzie są inni gracze. Twój statek automatycznie strzela pociskami, a ty próbujesz trafić w innych graczy, unikając jednocześnie ich pocisków.

1. Krótkie wprowadzenie / struktura projektu

Zalecam pobrać kod źródłowy przykładu gry, abyś mógł podążać za mną.

W przykładzie wykorzystano następujące technologie:

  • Express — najpopularniejszy framework webowy dla Node.js, zarządzający serwerem gry.
  • socket.io — biblioteka WebSocket do wymiany danych między przeglądarką a serwerem.
  • Webpack — menedżer modułów. O tym, dlaczego warto korzystać z Webpacka, można przeczytać tutaj.

Oto jak wygląda struktura katalogu projektu:

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

public/

Wszystko w folderze public/ będzie statycznie przekazywane przez serwer. W public/assets/ znajdują się obrazy używane przez nasz projekt.

src/

Cały kod źródłowy znajduje się w folderze src/. Nazwy client/ i server/ mówi sam za siebie, a shared/ zawiera plik stałych, który jest importowany zarówno przez klienta, jak i serwer.

2. Budowa / konfiguracja projektu

Jak wspomniano wcześniej, do budowy projektu korzystamy z menedżera modułów Webpack. Przyjrzyjmy się naszej konfiguracji 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',
    }),
  ],
};

Najważniejsze są następujące wiersze:

  • src/client/index.js — to punkt wejścia dla klienta Javascript (JS). Webpack rozpocznie stąd i zacznie rekurencyjnie szukać innych importowanych plików.
  • Wyjściowy JS naszej kompilacji Webpack znajdzie się w katalogu dist\/. Nazwiemy ten plik naszym pakietem JS.
  • Używamy Babel, a w szczególności konfigurację @babel\/preset-env do transpile'owania naszego kodu JS dla starszych przeglądarek.
  • Używamy pluginu do wydobywania wszystkich CSS, na które powołują się pliki JS, i do łączenia ich w jednym miejscu. Nazwiemy go naszym pakietem CSS.

Możesz zauważyć dziwne nazwy plików pakietów '[name].[contenthash].ext'. Zawierają one substytucje nazw plików Webpack: [name] zostanie zastąpione nazwą punktu wejścia (w naszym przypadku to game), a [contenthash] zostanie zastąpione skrótem zawartości pliku. Robimy to, aby optymalizować projekt pod kątem skrócone nazwy — można polecić przeglądarkom nieskończone buforowanie naszych pakietów JS, ponieważ gdy pakiet się zmienia, zmienia się też jego nazwa pliku (zmienia się contenthash). Ostatecznym wynikiem będzie nazwa pliku w formacie game.dbeee76e91a97d0c7207.js.

Plik webpack.common.js — to podstawowy plik konfiguracyjny, który importujemy w konfiguracjach deweloperskich i gotowych projektów. Oto przykład konfiguracji deweloperskiej:

webpack.dev.js

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

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

Aby zwiększyć wydajność, używamy podczas rozwoju webpack.dev.js, i przełącza się na webpack.prod.js, aby zoptymalizować rozmiary pakietów przy wdrażaniu na produkcję.

Lokalna konfiguracja

Zalecam uruchomienie projektu na lokalnej maszynie, aby mogli Państwo śledzić etapy wymienione w tej publikacji. Konfiguracja jest prosta: po pierwsze, w systemie muszą być zainstalowane Węzeł i NPM. Następnie należy wykonać

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

Jesteście gotowi do pracy! Aby uruchomić serwer deweloperski, wystarczy wykonać

$ npm run develop

i wejść do przeglądarki internetowej pod localhost:3000. Serwer deweloperski automatycznie przebuduje pakiety JS i CSS podczas wprowadzania zmian w kodzie — wystarczy odświeżyć stronę, aby zobaczyć wszystkie zmiany!

3. Punkty końcowe klienta

Zacznijmy od samego kodu gry. Na początek potrzebujemy strony index.html, która zostanie załadowana jako pierwsza, gdy odwiedzisz stronę. Nasza strona będzie dość prosta:

index.html

<!DOCTYPE html>
<html>
<head>
  <title>Przykładowa gra .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="Nazwa użytkownika" />
    <button id="play-button">GRAJ</button>
  </div>
</body>
</html>

Ten przykład kodu jest nieco uproszczony dla lepszego zrozumienia, a to samo zrobię z wieloma innymi przykładami w poście. Pełny kod można zawsze zobaczyć pod adresem Podróż, którą przeszedłem, okazała się fascynującą wyprawą w przeszłość i cieszę się, że w końcu znalazłem rozwiązanie. Poza tym:.

Mamy:

  • Element HTML5 Canvas (<canvas>), który będziemy używać do renderowania gry.
  • <link> aby dodać nasz pakiet CSS.
  • <script> aby dodać nasz pakiet Javascript.
  • Główne menu z nazwą użytkownika <input> i przyciskiem „GRAJ” (

Po załadowaniu strony głównej w przeglądarce zacznie działać kod Javascript, poczynając od pliku JS punktu wejścia: 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 = () => {
    // Graj!
    play(usernameInput.value);
    playMenu.classList.add('hidden');
    initState();
    startCapturingInput();
    startRendering();
    setLeaderboardHidden(false);
  };
});

To może wydawać się skomplikowane, ale w rzeczywistości dzieje się tutaj niewiele:

  1. Import kilku innych plików JS.
  2. Import CSS (żeby Webpack wiedział, że należy je uwzględnić w naszym pakiecie CSS).
  3. Uruchomienie connect() w celu nawiązania połączenia z serwerem i uruchomienia downloadAssets() w celu pobrania obrazów potrzebnych do renderowania gry.
  4. Po zakończeniu etapu 3 wyświetla się główne menu (playMenu).
  5. Ustawienie obsługi zdarzenia kliknięcia przycisku „GRAJ”. Po kliknięciu przycisku kod inicjalizuje grę i informuje serwer, że jesteśmy gotowi do gry.

Główna część naszej logiki klient-serwer znajduje się w plikach, które zostały zaimportowane przez plik index.js. Teraz przeanalizujemy je wszystkie po kolei.

4. Wymiana danych klienta

W tej grze do komunikacji z serwerem używamy dobrze znanej biblioteki socket.io. Socket.io ma wbudowane wsparcie WebSockets, które dobrze nadają się do komunikacji dwukierunkowej: możemy wysyłać wiadomości do serwera i serwer może wysyłać wiadomości do nas przez to samo połączenie.

Będziemy mieli jeden plik src/client/networking.js, który zajmie się wszystkimi komunikacjami z serwerem:

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('Połączono z serwerem!');
    resolve();
  });
});

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

Ten kod dla jasności też jest nieco skrócony.

W tym pliku zachodzą trzy główne działania:

  • Próbujemy połączyć się z serwerem. connectedPromise jest rozwiązywane tylko wtedy, gdy nawiążemy połączenie.
  • Jeśli połączenie zostało pomyślnie nawiązane, rejestrujemy funkcje callback (processGameUpdate() i onGameOver()) do wiadomości, które możemy otrzymywać z serwera.
  • Eksportujemy play() i updateDirection(), aby mogły być używane przez inne pliki.

5. Renderowanie klienta

Nadszedł czas, aby wyświetlić obraz na ekranie!

…ale zanim będziemy mogli to zrobić, musimy pobrać wszystkie obrazy (zasoby), które są do tego potrzebne. Napiszmy menedżera zasobów:

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

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

Zarządzanie zasobami nie jest takie trudne! Główna idea polega na tym, aby przechowywać obiekt assets, który będzie wiązał klucz nazwy pliku z wartością obiektu Obraz. Gdy zasób zostanie pobrany, zapisujemy go w obiekcie assets dla szybkiego dostępu w przyszłości. Gdy każde pojedyncze pobieranie zasobu zostanie dozwolone (tj. zasoby zostaną załadowane wszystkie ), zezwalamy na downloadPromise.

Po pobraniu zasobów możemy przystąpić do renderowania. Jak już wspomniano, do rysowania na stronie internetowej używamy HTML5 Canvas (<canvas>). Nasza gra jest dość prosta, więc wystarczy, że narysujemy tylko następujące elementy:

  1. Tło
  2. Statek gracza
  3. Innych graczy znajdujących się w grze
  4. Naboje

Oto ważne fragmenty src/client/render.js, które rysują dokładnie wymienione powyżej cztery punkty:

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;

// Uzyskaj kontekst graficzny płótna
const canvas = document.getElementById('game-canvas');
const context = canvas.getContext('2d');

// Uczyń płótno pełnoekranowym
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;

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

  // Rysuj tło
  renderBackground(me.x, me.y);

  // Rysuj wszystkie naboje
  bullets.forEach(renderBullet.bind(null, me));

  // Rysuj wszystkich graczy
  renderPlayer(me, me);
  others.forEach(renderPlayer.bind(null, me));
}

// ... Funkcje pomocnicze tutaj pominięte

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

Ten kod również został skrócony dla przejrzystości.

render() — to główna funkcja tego pliku. startRendering() i stopRendering() zarządzają aktywacją cyklu renderowania z częstotliwością 60 FPS.

Konkretne realizacje poszczególnych pomocniczych funkcji renderowania (na przykład renderBullet()) nie są takie ważne, ale oto jeden prosty przykład:

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

Zauważ, że używamy metody getAsset(), którą wcześniej widzieliśmy w asset.js!

Jeśli chcesz zgłębić inne pomocnicze funkcje renderowania, przeczytaj resztę src/client/render.js.

6. Wprowadzenie od klienta

Nadszedł czas, aby uczynić grę grającą! Schemat sterowania będzie bardzo prosty: aby zmienić kierunek ruchu, można użyć myszy (na komputerze) lub dotyków ekranu (na urządzeniu mobilnym). Aby to zrealizować, zarejestrujemy Event Listenerów na zdarzenia myszy i dotyku.
Zajmie się tym 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() i onTouchInput() — to Event Listenery, które wywołują updateDirection() (z networking.js) przy wykonaniu zdarzenia wejścia (na przykład podczas poruszania myszą). updateDirection() zajmuje się wymianą wiadomości z serwerem, który przetwarza zdarzenie wejścia i odpowiednio aktualizuje stan gry.

7. Stan klienta

Ten dział jest najtrudniejszy w pierwszej części posta. Nie zniechęcaj się, jeśli nie zrozumiesz go za pierwszym razem! Możesz nawet go pominąć i wrócić do niego później.

Ostatni element układanki potrzebny do zakończenia kodu klient-serwer to stan. Pamiętasz fragment kodu z sekcji „Renderowanie klienta”?

render.js

import { getCurrentState } from '. /state';

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

  // Wykonaj renderowanie
  // ...
}

getCurrentState() powinien być w stanie dostarczyć nam bieżący stan gry na kliencie w dowolnym momencie na podstawie aktualizacji otrzymywanych od serwera. Oto przykład aktualizacji gry, którą może wysyłać serwer:

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

Każda aktualizacja gry zawiera pięć identycznych pól:

  • t: znacznik czasu serwera, oznaczający moment utworzenia tej aktualizacji.
  • me: informacje o graczu, który otrzymuje tę aktualizację.
  • others: tablica z informacjami o innych graczach, którzy biorą udział w tej samej grze.
  • bullets: tablica z informacjami o pociskach w grze.
  • leaderboard: bieżące dane tabeli liderów. W tym poście nie będziemy ich uwzględniać.

7.1 Naiwne państwo klienta

Naiwna implementacja getCurrentState() może tylko bezpośrednio zwracać dane ostatniej otrzymanej aktualizacji gry.

naive-state.js

let lastGameUpdate = null;

// Obsłuż nowo odebraną aktualizację gry.
export function processGameUpdate(update) {
  lastGameUpdate = update;
}

export function getCurrentState() {
  return lastGameUpdate;
}

Pięknie i jasno! Ale gdyby wszystko było takie proste. Jednym z powodów, dla których taka implementacja jest problematyczna: ogranicza częstotliwość klatek renderowania do częstotliwości taktowania serwera.

Częstotliwość klatek (Frame Rate): liczba klatek (t.j. wywołań render()) na sekundę, lub FPS. W grach zwykle dąży się do osiągnięcia co najmniej 60 FPS.

Częstotliwość taktów (Tick Rate): częstotliwość, z jaką serwer wysyła aktualizacje gry do klientów. Często jest ona niższa niż częstotliwość klatek. W naszej grze serwer działa z częstotliwością 30 taktów na sekundę.

Jeśli będziemy renderować tylko ostatnią aktualizację gry, to FPS praktycznie nigdy nie przekroczy 30, ponieważ nigdy nie otrzymujemy od serwera więcej niż 30 aktualizacji na sekundę.. Nawet jeśli będziemy wywoływać render() 60 razy na sekundę, to połowa tych wywołań będzie po prostu rysować to samo, praktycznie nie robiąc nic. Kolejnym problemem naiwnej implementacji jest to, że jest ona narażona na opóźnienia.. Przy idealnej prędkości Internetu klient będzie otrzymywał aktualizację gry co 33 ms (30 razy na sekundę):

Tworzenie wieloosobowej gry internetowej w stylu .io
Niestety, nic nie jest idealne. Bardziej realistyczny byłby taki obraz:
Tworzenie wieloosobowej gry internetowej w stylu .io
Naiwna implementacja to praktycznie najgorszy przypadek, jeśli chodzi o opóźnienia. Jeśli aktualizacja gry jest przyjmowana z opóźnieniem 50 ms, to klient spóźnia się o dodatkowe 50 ms, ponieważ nadal renderuje stan gry z poprzedniej aktualizacji. Możesz sobie wyobrazić, jak bardzo to jest niewygodne dla gracza: z powodu losowych opóźnień gra wydaje się szarpana i niestabilna.

7.2 Ulepszony stan klienta

Wprowadzimy pewne ulepszenia do naiwnej implementacji. Po pierwsze, użyjemy opóźnienia renderowania o 100 ms. Oznacza to, że 'bieżący' stan klienta zawsze będzie opóźniony o 100 ms względem stanu gry na serwerze. Na przykład, jeśli na serwerze czas wynosi 150, to na kliencie będzie renderowany stan, w którym był serwer w czasie 50:

Tworzenie wieloosobowej gry internetowej w stylu .io
To daje nam bufor 100 ms, pozwalający przetrwać nieprzewidywalny czas odbierania aktualizacji gry:

Tworzenie wieloosobowej gry internetowej w stylu .io
Ceną za to będzie stałe opóźnienie wejścia (input lag) o 100 ms. To niewielka ofiara za płynny przebieg gry — większość graczy (zwłaszcza casualowych) nawet nie zauważy tego opóźnienia. Ludziom znacznie łatwiej jest przystosować się do stałego opóźnienia wynoszącego 100 ms, niż grać z nieprzewidywalnym opóźnieniem.

Możemy również użyć innej techniki zwanej 'prognozowanie po stronie klienta', która dobrze radzi sobie z obniżaniem postrzeganych opóźnień, ale w tym poście nie będzie ona omawiana.

Kolejnym ulepszeniem, które stosujemy, jest interpolacja liniowa.Z powodu opóźnienia renderowania zazwyczaj wyprzedzamy obecny czas w kliencie o co najmniej jedną aktualizację. Kiedy zostanie wywołane getCurrentState(), możemy wykonać interpolację liniową. między aktualizacjami gry tuż przed i po bieżącym czasie w kliencie:

Tworzenie wieloosobowej gry internetowej w stylu .io
To rozwiązuje problem z liczba klatek na sekundę: teraz możemy renderować unikalne klatki z każdą potrzebną nam częstotliwością!

7.3 Wdrożenie ulepszonego stanu klienta

Przykład wdrożenia w src/client/state.js wykorzystuje zarówno opóźnienie renderowania, jak i liniową interpolację, ale tylko przez krótki czas. Podzielmy kod na dwie części. Oto pierwsza:

state.js, część 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);

  // Zachowaj tylko jedną aktualizację gry przed bieżącym czasem serwera
  const base = getBaseUpdate();
  if (base > 0) {
    gameUpdates.splice(0, base);
  }
}

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

// Zwraca indeks podstawowej aktualizacji, pierwszej aktualizacji gry przed
// bieżącym czasem serwera, albo -1 jeśli 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;
}

Pierwszą rzeczą, którą musimy zrozumieć, jest to, co robi currentServerTime(). Jak widzieliśmy wcześniej, każda aktualizacja gry zawiera znacznik czasu serwera. Chcemy użyć opóźnienia renderowania, aby renderować obraz, opóźniając się za serwerem o 100 ms, ale nigdy nie dowiemy się, jakie jest bieżące czas na serwerze, ponieważ nie możemy wiedzieć, jak długo docierała do nas jakakolwiek z aktualizacji. Internet jest nieprzewidywalny i jego prędkość może się bardzo różnić!

Aby obejść ten problem, możemy użyć rozsądnej aproksymacji: my udawajmy, że pierwsza aktualizacja dotarła natychmiast. Gdyby to było prawda, wiedzielibyśmy, jakie jest czas serwera w danym momencie! Przechowujemy znacznik czasu serwera w firstServerTimestamp i zapamiętujemy nasz lokalny (kliencki) znacznik czasu w tym samym momencie w gameStart.

O, chwileczkę. Czy czas na serwerze nie powinien być równy czasowi w kliencie? Dlaczego różnicujemy znacznik czasu serwera i znacznik czasu klienta? To dobre pytanie! Okazuje się, że to nie to samo. Date.now() zwraca różne znaczniki czasu w kliencie i na serwerze, i zależy to od lokalnych czynników tych maszyn. Nigdy nie zakładaj, że znaczniki czasu będą identyczne na wszystkich maszynach.

Teraz rozumiemy, co robi currentServerTime(): zwraca znacznik czasu serwera dla bieżącego czasu renderowania. Innymi słowy, to aktualny czas serwera (firstServerTimestamp < + (Date.now() - gameStart)) minus opóźnienie renderowania (RENDER_DELAY).

Teraz przyjrzyjmy się, jak przetwarzamy aktualizacje gry. Gdy otrzymujemy aktualizację z serwera, wywoływana jest processGameUpdate(), a my zapisujemy nową aktualizację w tablicy gameUpdates. Następnie, aby sprawdzić wykorzystanie pamięci, usuwamy wszystkie stare aktualizacje do aktualizacji bazowej, ponieważ już ich nie potrzebujemy.

Czym zatem jest „aktualizacja bazowa”? To pierwsza aktualizacja, którą znajdujemy, przesuwając się wstecz od aktualnego czasu serwera. Pamiętasz ten schemat?

Tworzenie wieloosobowej gry internetowej w stylu .io
Aktualizacja gry bezpośrednio na lewo od „Czasu renderowania klienta” jest aktualizacją bazową.

Do czego używamy aktualizacji bazowej? Dlaczego możemy odrzucać aktualizacje do bazy? Aby to wyjaśnić, przyjrzyjmy się w końcu implementacji getCurrentState():

state.js, część 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),
    };
  }
}

Zajmujemy się trzema przypadkami:

  1. base < 0 oznacza, że przed aktualnym czasem renderowania nie ma aktualizacji (patrz wyżej implementację getBaseUpdate()). Może to się zdarzyć na początku gry z powodu opóźnienia renderowania. W takim przypadku używamy najnowszej otrzymanej aktualizacji.
  2. base — to najnowsza aktualizacja, jaką mamy. Może to się zdarzyć z powodu opóźnienia sieciowego lub złego połączenia z Internetem. W tym przypadku również używamy najnowszej aktualizacji, jaką mamy.
  3. Mamy aktualizację zarówno przed, jak i po aktualnym czasie renderowania, więc możemy interpolować!

Wszystko co pozostało w state.js — to implementacja interpolacji liniowej, która jest prostą (ale nudną) matematyką. Jeśli chcesz zgłębić to na własną rękę, otwórz state.js na Podróż, którą przeszedłem, okazała się fascynującą wyprawą w przeszłość i cieszę się, że w końcu znalazłem rozwiązanie. Poza tym:.

Część 2. Serwer backendowy

W tej części omówimy backend Node.js, który zarządza naszym przykładem gry .io.

1. Punkt wejścia serwera

Aby zarządzać serwerem www, użyjemy popularnego frameworka www dla Node.js o nazwie Express. Jego konfiguracją zajmie się nasz plik wejścia serwera src/server/server.js:

server.js, część 1

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

// Ustawienie serwera Express
const app = express();
app.use(express.static('public'));

if (process.env.NODE_ENV === 'development') {
  // Ustawienie Webpacka dla deweloperów
  const compiler = webpack(webpackConfig);
  app.use(webpackDevMiddleware(compiler));
} else {
  // Statyczne serwowanie folderu dist\/ w produkcji
  app.use(express.static('dist'));
}

// Nasłuchiwanie na porcie
const port = process.env.PORT || 3000;
const server = app.listen(port);
console.log(`Serwer nasłuchuje na porcie ${port}`);

Pamiętaj, że w pierwszej części omawialiśmy Webpack? Tutaj wykorzystamy nasze konfiguracje Webpack. Będziemy je stosować na dwa sposoby:

  • Użyć webpack-dev-middleware do automatycznego przebudowywania naszych pakietów deweloperskich, lub
  • Statycznie przekazywać folder dist\/, do którego Webpack zapisze nasze pliki po zbudowaniu produkcji.

Kolejnym ważnym zadaniem server.js jest skonfigurowanie serwera socket.io, który po prostu łączy się z serwerem Express:

server.js, część 2

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

// Ustawienie Express
// ...
const server = app.listen(port);
console.log(`Serwer nasłuchuje na porcie ${port}`);

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

// Nasłuchiwanie połączeń socket.io
io.on('connection', socket => {
  console.log('Gracz połączony!', socket.id);

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

Po pomyślnym nawiązaniu połączenia socket.io z serwerem, konfigurujemy obsługiwacze zdarzeń dla nowego gniazda. Obsługiwacze zdarzeń przetwarzają wiadomości otrzymywane od klientów, delegując do obiektu singletonu game:

server.js, część 3

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

// ...

// Ustawienie gry
const game = new Game();

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

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

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

Tworzymy grę typu .io, więc potrzebujemy tylko jednego egzemplarza Game („Gra”) – wszyscy gracze grają na jednej arenie! W następnej części przyjrzymy się, jak działa ta klasa Game.

2. Gra serwera

Klasa Game zawiera najważniejszą logikę po stronie serwera. Ma dwa główne zadania: zarządzanie graczami i symulacja gry.

Zacznijmy od pierwszego zadania – od zarządzania graczami.

game.js, część 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;

    // Generuj pozycję, z której zacznie ten gracz.
    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);
    }
  }

  // ...
}

W tej grze będziemy identyfikować graczy po ich polu id ich socket.io (jeśli się zgubisz, wróć z powrotem do server.js). Socket.io sam przypisuje każdemu socketowi unikalny id, więc nie musimy się o to martwić. Będę nazywał to ID gracza.

Zapamiętując to, przyjrzyjmy się zmiennym instancji w klasie Game:

  • sockets — to obiekt, który wiąże ID gracza z socketem, który jest związany z graczem. Umożliwia nam to dostęp do socketów po ich ID graczy w stałym czasie.
  • players — to obiekt, który wiąże ID gracza z obiektem code>Player

bullets — to tablica obiektów Bullet, nie mający określonego porządku.
lastUpdateTime — to znacznik czasu ostatniego aktualizacji gry. Wkrótce zobaczymy, jak jest używany.
shouldSendUpdate — to zmienna pomocnicza. Jej użycie również zobaczymy wkrótce.
Metody addPlayer(), removePlayer() i handleInput() nie trzeba tłumaczyć, są używane w server.js. Jeśli potrzebujesz odświeżyć pamięć, wróć trochę wyżej.

Ostatnia linia constructor() uruchamia cykl aktualizacji gry (z częstotliwością 60 aktualizacji/s):

game.js, część 2

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

class Game {
  \/\/ ...

  update() {
    \/\/ Oblicz czas, który upłynął
    const now = Date.now();
    const dt = (now - this.lastUpdateTime) \/ 1000;
    this.lastUpdateTime = now;

    \/\/ Zaktualizuj każdy pocisk
    const bulletsToRemove = [];
    this.bullets.forEach(bullet => {
      if (bullet.update(dt)) {
        \/\/ Zniszcz ten pocisk
        bulletsToRemove.push(bullet);
      }
    });
    this.bullets = this.bullets.filter(
      bullet => !bulletsToRemove.includes(bullet),
    );

    \/\/ Zaktualizuj każdego gracza
    Object.keys(this.sockets).forEach(playerID => {
      const player = this.players[playerID];
      const newBullet = player.update(dt);
      if (newBullet) {
        this.bullets.push(newBullet);
      }
    });

    \/\/ Zastosuj kolizje, przyznaj graczom punkty za trafienie w pociski
    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),
    );

    \/\/ Sprawdź, czy którykolwiek z graczy jest martwy
    Object.keys(this.sockets).forEach(playerID => {
      const socket = this.sockets[playerID];
      const player = this.players[playerID];
      if (player.hp  {
        const socket = this.sockets[playerID];
        const player = this.players[playerID];
        socket.emit(
          Constants.MSG_TYPES.GAME_UPDATE,
          this.createUpdate(player, leaderboard),
        );
      });
      this.shouldSendUpdate = false;
    } else {
      this.shouldSendUpdate = true;
    }
  }

  \/\/ ...
}

Metoda update() zawiera prawdopodobnie najważniejszą część logiki po stronie serwera. Po kolei wymieńmy wszystko, co robi:

  1. Oblicza, ile czasu dt minęło od ostatniego update().
  2. Aktualizuje każdy pocisk i w razie potrzeby je niszczy. Implementację tej funkcjonalności zobaczymy później. Na razie wystarczy nam wiedzieć, że bullet.update() zwraca true, jeśli pocisk powinien zostać zniszczony (wyszedł poza granice areny).
  3. Aktualizuje każdego gracza i w razie potrzeby tworzy pocisk. Tę implementację również zobaczymy później — player.update() może zwrócić obiekt Bullet.
  4. Sprawdza kolizje między pociskami a graczami za pomocą applyCollisions(), który zwraca tablicę pocisków, które trafiły w graczy. Dla każdego zwróconego pocisku zwiększamy punkty gracza, który go wystrzelił (za pomocą player.onDealtDamage()), a następnie usuwamy pocisk z tablicy. bullets.
  5. Powiadamia i niszczy wszystkich zabitych graczy.
  6. Wysyła wszystkim graczom aktualizację gry co drugi raz podczas wywołania update(). Pomaga nam to śledzić wspomnianą wcześniej zmienną pomocniczą shouldSendUpdate. Tak jak update() jest wywoływana 60 razy/s, a my wysyłamy aktualizacje gry 30 razy/s. W ten sposób, częstotliwość taktowania serwera wynosi 30 taktów/s (mówiliśmy o częstotliwości taktowania w pierwszej części).

Po co wysyłać aktualizacje gry tylko co drugą ? dla oszczędności pasma. 30 aktualizacji gry na sekundę to naprawdę dużo!

Dlaczego więc po prostu nie wywoływać update() 30 razy na sekundę? Aby poprawić symulację gry. Im częściej się wywołuje update(), tym dokładniejsza będzie symulacja gry. Ale nie należy przesadzać z liczbą wywołań update(), ponieważ jest to kosztowna operacja — 60 na sekundę w zupełności wystarczy.

Pozostała część klasy Game składa się z pomocniczych metod używanych w update():

game.js, część 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() jest dość prosty – sortuje graczy według liczby punktów, bierze pięciu najlepszych i zwraca dla każdego nazwę użytkownika oraz wynik.

createUpdate() jest używana w update() do tworzenia aktualizacji gry, które są przesyłane do graczy. Jej głównym zadaniem jest wywoływanie metod serializeForUpdate(), zaimplementowanych dla klas Player i Bullet. Zauważ, że przesyła każdemu graczowi dane tylko o bliskich graczach i pociskach – nie ma potrzeby przesyłania informacji o obiektach gry, które znajdują się daleko od gracza!

3. Obiekty gry na serwerze

W naszej grze pociski i gracze są w rzeczywistości bardzo podobni: to abstrakcyjne okrągłe ruchome obiekty gry. Aby skorzystać z tej podobieństwa graczy i pocisków, zacznijmy od zaimplementowania podstawowej klasy 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,
    };
  }
}

Tu niczego skomplikowanego nie ma. Ta klasa stanie się dobrą bazą do rozbudowy. Zobaczmy, jak klasa Bullet używa 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;
  }

  // Zwraca true, jeśli pocisk powinien zostać zniszczony
  update(dt) {
    super.update(dt);
    return this.x  Constants.MAP_SIZE || this.y  Constants.MAP_SIZE;
  }
}

Realizacja Bullet jest bardzo krótka! Dodaliśmy do Object tylko następujące rozszerzenia:

  • Użycie pakietu shortid do losowego generowania id pocisku.
  • Dodanie pola parentID, aby można było śledzić gracza, który stworzył ten pocisk.
  • Dodanie wartości zwracanej w update(), która wynosi true, jeśli pocisk znajduje się poza areną (pamiętacie, mówiliśmy o tym w poprzedniej części?).

Przejdźmy do 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;
  }

  // Zwraca nowo utworzony pocisk lub null.
  update(dt) {
    super.update(dt);

    // Aktualizuje wynik
    this.score += dt * Constants.SCORE_PER_SECOND;

    // Upewnia się, że gracz pozostaje w granicach
    this.x = Math.max(0, Math.min(Constants.MAP_SIZE, this.x));
    this.y = Math.max(0, Math.min(Constants.MAP_SIZE, this.y));

    // Ognia pocisk, jeśli to konieczne
    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,
    };
  }
}

Gracze są bardziej skomplikowani niż pociski, więc ta klasa powinna przechowywać dodatkowe pola. Jej metoda update() wykonuje większą część pracy, w szczególności zwraca właśnie utworzony pocisk, jeśli nie zostało już fireCooldown (pamiętacie, mówiliśmy o tym w poprzedniej części?). Rozszerza także metodę serializeForUpdate(), ponieważ musimy uwzględnić w aktualizacji gry dodatkowe pola dla gracza.

Posiadanie klasy bazowej Object to ważny krok, który pozwala uniknąć powtarzalności kodu. Na przykład, bez klasy Object każdy obiekt gry musiałby mieć identyczną implementację distanceTo(), a synchronizacja kopiowanej implementacji we wszystkich tych plikach byłaby koszmarem. To staje się szczególnie ważne w dużych projektach, gdy liczba klas rozszerzających rośnie. Object 4. Rozpoznawanie kolizji

4. Rozpoznawanie kolizji

Jedyną rzeczą, która nam pozostała, jest rozpoznawanie, kiedy pociski trafiają w graczy! Przypomnijcie sobie ten fragment kodu z metody update() w klasie Game:

game.js

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

class Game {
  // ...

  update() {
    // ...

    // Zastosuj kolizje, przyznaj graczom punkty za trafienie w pociski
    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),
    );

    // ...
  }
}

Musimy zaimplementować metodę applyCollisions(), która zwróci wszystkie pociski, które trafiły w graczy. Na szczęście, nie jest to tak trudne do zrealizowania, ponieważ

  • Wszystkie obiekty kolidujące są okręgami, co sprawia, że rozpoznawanie kolizji jest prostą do zaimplementowania figurą.
  • Mamy już metodę distanceTo(), którą zaimplementowaliśmy w poprzedniej sekcji w klasie Object.

Oto jak wygląda nasza implementacja rozpoznawania kolizji:

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

To proste rozpoznawanie kolizji opiera się na tym, że dwa okręgi kolidują, jeśli odległość między ich środkami jest mniejsza niż suma ich promieni.Oto przypadek, gdy odległość między środkami dwóch okręgów dokładnie równa się sumie ich promieni:

Tworzenie wieloosobowej gry internetowej w stylu .io
Tutaj należy zwrócić uwagę na kilka aspektów:

  • Pocisk nie powinien trafić w gracza, który go stworzył. Można to osiągnąć poprzez porównanie bullet.parentID z player.id.
  • Pocisk powinien trafić tylko raz w przypadku jednoczesnej kolizji z wieloma graczami. Rozwiążemy ten problem za pomocą operatora break: gdy tylko znajdziemy gracza, który zderzył się z pociskiem, przerywamy poszukiwania i przechodzimy do następnego pocisku.

Koniec

I to wszystko! Przeanalizowaliśmy wszystko, co należy wiedzieć o tworzeniu gry przeglądarkowej w stylu .io. Co dalej? Stwórz swoją własną grę .io!

Cały kod z przykładu jest dostępny w otwartych źródłach i opublikowany na Podróż, którą przeszedłem, okazała się fascynującą wyprawą w przeszłość i cieszę się, że w końcu znalazłem rozwiązanie. Poza tym:.

Źródło: habr.com

Kup solidny hosting stron z ochroną przed DDoS, serwery VPS VDS 🔥 Kup solidny hosting stron z ochroną przed DDoS, serwery VPS VDS | ProHoster