Tworzenie wieloosobowej gry internetowej .io

Tworzenie wieloosobowej gry internetowej .io
Wydany w 2015 roku Agar.io stał się prekursorem nowego gatunku gry .io, którego popularność od tego czasu znacznie wzrosła. Sam doświadczyłem wzrostu popularności gier .io: w ciągu ostatnich trzech lat I stworzył i sprzedał dwie gry z tego gatunku..

Jeśli nigdy wcześniej nie słyszałeś o tych grach, są to bezpłatne gry internetowe dla wielu graczy, w które łatwo grać (nie jest wymagane konto). Zwykle walczą z wieloma przeciwnikami na tej samej arenie. Inne znane gry .io: Slither.io и Diep.io.

W tym poście dowiemy się, jak to zrobić utwórz grę .io od podstaw. W tym celu wystarczy znajomość JavaScript: musisz zrozumieć takie rzeczy, jak składnia ES6, słowo kluczowe this и Obietnice. Nawet jeśli Twoja znajomość Javascriptu nie jest doskonała, nadal możesz zrozumieć większość postu.

Przykład gry .io

Aby uzyskać pomoc w nauce, będziemy się odnosić do Przykład gry .io. Spróbuj w to zagrać!

Tworzenie wieloosobowej gry internetowej .io
Gra jest dość prosta: sterujesz statkiem na arenie, na której znajdują się inni gracze. Twój statek automatycznie wystrzeliwuje pociski, a ty starasz się trafiać innych graczy, unikając ich pocisków.

1. Krótki przegląd/struktura projektu

POLECAM pobierz kod źródłowy przykładowa gra, dzięki której możesz mnie śledzić.

W przykładzie zastosowano następujące elementy:

  • wyrazić to najpopularniejszy framework sieciowy Node.js zarządzający serwerem WWW gry.
  • gniazdo.io - biblioteka websocket do wymiany danych pomiędzy przeglądarką a serwerem.
  • Webpack - menadżer modułu. Możesz przeczytać o tym, dlaczego warto używać pakietu Webpack. tutaj.

Oto jak wygląda struktura katalogów projektu:

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

publiczny/

Wszystko jest w folderze public/ zostaną przesłane statycznie przez serwer. W public/assets/ zawiera obrazy wykorzystane w naszym projekcie.

src /

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

2. Ustawienia złożeń/projektu

Jak wspomniano powyżej, do budowy projektu używamy menedżera modułów. Webpack. Przyjrzyjmy się konfiguracji naszego pakietu internetowego:

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 linie tutaj to:

  • src/client/index.js jest punktem wejścia klienta Javascript (JS). Pakiet Webpack rozpocznie się stąd i będzie wyszukiwał rekursywnie inne zaimportowane pliki.
  • Wyjściowy kod JS naszej kompilacji pakietu Webpack będzie zlokalizowany w katalogu dist/. Nazwę ten plik naszym pakiet js.
  • Używamy Babel, a w szczególności konfigurację @babel/preset-env do transpilacji naszego kodu JS dla starszych przeglądarek.
  • Używamy wtyczki, aby wyodrębnić wszystkie CSS, do których odwołują się pliki JS i połączyć je w jednym miejscu. Nazwę go naszym Pakiet CSS.

Być może zauważyłeś dziwne nazwy plików pakietów '[name].[contenthash].ext'. Zawierają podstawienie nazwy pliku Pakiet internetowy: [name] zostanie zastąpiony nazwą punktu wejściowego (w naszym przypadku this game) i [contenthash] zostanie zastąpiony skrótem zawartości pliku. Robimy to zoptymalizuj projekt pod kątem mieszania - możesz nakazać przeglądarkom buforowanie naszych pakietów JS na czas nieokreślony, ponieważ jeśli pakiet się zmieni, zmieni się także jego nazwa pliku (zmiany contenthash). Ostatecznym wynikiem będzie nazwa pliku widoku game.dbeee76e91a97d0c7207.js.

plik webpack.common.js - Jest to podstawowy plik konfiguracyjny, który importujemy do konfiguracji deweloperskiej i gotowego projektu. Oto przykładowa konfiguracja programistyczna:

webpack.dev.js

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

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

Dla efektywności używamy w procesie rozwoju webpack.dev.jsi przełącza na webpack.prod.jsw celu optymalizacji rozmiarów pakietów podczas wdrażania do środowiska produkcyjnego.

Krajobraz lokalny

Zalecam zainstalowanie projektu na komputerze lokalnym, abyś mógł wykonać kroki wymienione w tym poście. Konfiguracja jest prosta: najpierw należy zainstalować system Node и NPM. Następnie musisz zrobić

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

i jesteś gotowy do pracy! Aby uruchomić serwer programistyczny, po prostu uruchom

$ npm run develop

i przejdź do przeglądarki internetowej localhost: 3000. Serwer deweloperski automatycznie odbuduje pakiety JS i CSS po zmianie kodu — wystarczy odświeżyć stronę, aby zobaczyć wszystkie zmiany!

3. Punkty wejścia klienta

Przejdźmy do samego kodu gry. Najpierw potrzebujemy strony index.html, kiedy odwiedzisz witrynę, przeglądarka załaduje ją jako pierwszą. Nasza strona będzie dość prosta:

index.html

Przykładowa gra .io  GRAĆ

Ten przykładowy kod został nieco uproszczony dla przejrzystości i zrobię to samo z wieloma innymi przykładami postów. Pełny kod można zawsze zobaczyć pod adresem Github.

Mamy:

  • Element płótna HTML5 (<canvas>), którego użyjemy do renderowania gry.
  • <link> aby dodać nasz pakiet CSS.
  • <script> aby dodać nasz pakiet JavaScript.
  • Menu główne z nazwą użytkownika <input> i przycisk „PLAY” (<button>).

Po załadowaniu strony głównej przeglądarka rozpocznie wykonywanie kodu JavaScript, zaczynając od punktu wejściowego pliku JS: src/client/index.js.

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

Może to wydawać się skomplikowane, ale niewiele się tutaj dzieje:

  1. Zaimportuj kilka innych plików JS.
  2. Zaimportuj CSS (aby Webpack wiedział, że ma je dołączyć do naszego pakietu CSS).
  3. szalupa connect() aby nawiązać połączenie z serwerem i uruchomić downloadAssets() aby pobrać obrazy potrzebne do renderowania gry.
  4. Po ukończeniu etapu 3 wyświetli się menu główne (playMenu).
  5. Ustawienie procedury obsługi naciśnięcia przycisku „PLAY”. Po naciśnięciu przycisku kod inicjuje grę i informuje serwer, że jesteśmy gotowi do gry.

Główne „mięso” naszej logiki klient-serwer znajduje się w plikach, które zostały zaimportowane przez plik index.js. Teraz rozważymy je wszystkie w kolejności.

4. Wymiana danych klientów

W tej grze do komunikacji z serwerem wykorzystujemy dobrze znaną bibliotekę gniazdo.io. Socket.io ma natywną obsługę WebSockets, które doskonale nadają się do komunikacji dwukierunkowej: możemy wysyłać wiadomości na serwer и serwer może wysyłać do nas wiadomości za pośrednictwem tego samego połączenia.

Będziemy mieli jeden plik src/client/networking.jskto się zaopiekuje przez wszystkich komunikacja 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('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);
};

Kod ten również został nieco skrócony dla przejrzystości.

W tym pliku znajdują się trzy główne działania:

  • Próbujemy połączyć się z serwerem. connectedPromise dozwolone tylko wtedy, gdy nawiązaliśmy połączenie.
  • Jeśli połączenie się powiedzie rejestrujemy funkcje wywołania zwrotnego (processGameUpdate() и onGameOver()) dla wiadomości, które możemy otrzymać z serwera.
  • Eksportujemy play() и updateDirection()aby inne pliki mogły z nich korzystać.

5. Renderowanie klienta

Czas wyświetlić obraz na ekranie!

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

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

Zarządzanie zasobami nie jest takie trudne do wdrożenia! Głównym celem jest przechowywanie obiektu assets, który powiąże klucz nazwy pliku z wartością obiektu Image. Po załadowaniu zasobu przechowujemy go w obiekcie assets aby mieć do nich szybki dostęp w przyszłości. Kiedy będzie dozwolone pobieranie poszczególnych zasobów (tzn wszystko zasobów), pozwalamy downloadPromise.

Po pobraniu zasobów możesz rozpocząć renderowanie. Jak powiedziano wcześniej, do rysowania na stronie internetowej używamy Płótno HTML5 (<canvas>). Nasza gra jest dość prosta, więc musimy tylko narysować następujące elementy:

  1. tło
  2. Statek gracza
  3. Inni gracze w grze
  4. Muszle

Oto najważniejsze fragmenty src/client/render.js, które renderują dokładnie cztery elementy wymienione powyżej:

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

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

render() jest główną funkcją tego pliku. startRendering() и stopRendering() kontroluj aktywację pętli renderowania przy 60 FPS.

Konkretne implementacje indywidualnych funkcji pomocniczych renderowania (np. renderBullet()) nie są aż tak 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 tej metody getAsset(), który można było zobaczyć wcześniej w asset.js!

Jeśli chcesz poznać inne pomoce renderujące, przeczytaj resztę. src/client/render.js.

6. Dane klienta

Czas stworzyć grę grywalne! Schemat sterowania będzie bardzo prosty: aby zmienić kierunek ruchu, możesz użyć myszy (na komputerze) lub dotknąć ekranu (na urządzeniu mobilnym). Aby to wdrożyć, zarejestrujemy się Słuchacze zdarzeń dla wydarzeń związanych z myszą i dotykiem.
Zajmę się tym wszystkim src/client/input.js:

wejście.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() to słuchacze zdarzeń, którzy dzwonią updateDirection() (od networking.js) w przypadku wystąpienia zdarzenia wejściowego (na przykład ruchu myszy). updateDirection() obsługuje komunikację z serwerem, który obsługuje zdarzenie wejściowe i odpowiednio aktualizuje stan gry.

7. Państwo Klienta

Ta część jest najtrudniejsza w pierwszej części wpisu. Nie zniechęcaj się, jeśli nie zrozumiesz tekstu za pierwszym razem! Można go nawet pominąć i wrócić do niego później.

Ostatnim elementem układanki potrzebnym do ukończenia kodu klienta/serwera jest były. Pamiętasz fragment kodu z sekcji Renderowanie klienta?

render.js

import { getCurrentState } from './state';

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

  // Do the rendering
  // ...
}

getCurrentState() powinien być w stanie podać nam aktualny stan gry w kliencie w dowolnym momencie na podstawie aktualizacji otrzymanych z serwera. Oto przykład aktualizacji gry, którą serwer może wysłać:

{
  "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żda aktualizacja gry zawiera pięć identycznych pól:

  • t: sygnatura czasowa serwera wskazująca, kiedy utworzono tę aktualizację.
  • me: Informacje o graczu otrzymującym tę aktualizację.
  • inni: Zbiór informacji o innych graczach biorących udział w tej samej grze.
  • kule: tablica informacji o pociskach w grze.
  • liderów: Aktualne dane tabeli liderów. W tym poście nie będziemy ich rozważać.

7.1 Naiwny stan klienta

Naiwne wdrożenie getCurrentState() może jedynie bezpośrednio zwrócić dane dotyczące ostatnio otrzymanej aktualizacji gry.

naive-state.js

let lastGameUpdate = null;

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

export function getCurrentState() {
  return lastGameUpdate;
}

Ładnie i przejrzyście! Ale gdyby to było takie proste. Jednym z powodów, dla których ta implementacja jest problematyczna: ogranicza liczbę klatek renderowania do częstotliwości zegara serwera.

Częstotliwość wyświetlania klatek: liczba ramek (tj. wywołań render()) na sekundę lub FPS. Gry zazwyczaj dążą do osiągnięcia co najmniej 60 FPS.

Zaznacz stawkę: Częstotliwość, z jaką serwer wysyła aktualizacje gier do klientów. Często jest niższa niż liczba klatek na sekundę. W naszej grze serwer działa z częstotliwością 30 cykli na sekundę.

Jeśli po prostu wyrenderujemy najnowszą aktualizację gry, FPS w zasadzie nigdy nie przekroczy 30, ponieważ nigdy nie otrzymujemy z serwera więcej niż 30 aktualizacji na sekundę. Nawet jeśli zadzwonimy render() 60 razy na sekundę, to połowa tych wywołań po prostu przerysuje to samo, zasadniczo nic nie robiąc. Kolejnym problemem związanym z naiwną implementacją jest to, że tak podatne na opóźnienia. Przy idealnej szybkości Internetu klient będzie otrzymywał aktualizację gry dokładnie co 33 ms (30 na sekundę):

Tworzenie wieloosobowej gry internetowej .io
Niestety, nic nie jest idealne. Bardziej realistyczny obraz byłby taki:
Tworzenie wieloosobowej gry internetowej .io
Naiwna implementacja jest praktycznie najgorszym przypadkiem, jeśli chodzi o opóźnienia. Jeśli aktualizacja gry zostanie odebrana z opóźnieniem 50 ms, wówczas klient jest spowolniony dodatkowe 50 ms, ponieważ nadal renderuje stan gry z poprzedniej aktualizacji. Możesz sobie wyobrazić, jak niewygodne jest to dla gracza: dowolne hamowanie sprawi, że gra będzie szarpana i niestabilna.

7.2 Ulepszony stan klienta

Wprowadzimy pewne ulepszenia do naiwnej implementacji. Po pierwsze, używamy opóźnienie renderowania o 100 ms. Oznacza to, że „bieżący” stan klienta będzie zawsze 100 ms gorszy od stanu gry na serwerze. Na przykład, jeśli czas na serwerze to 150, wówczas klient wyrenderuje stan, w jakim znajdował się w tym czasie serwer 50:

Tworzenie wieloosobowej gry internetowej .io
Daje nam to bufor 100 ms, pozwalający przetrwać nieprzewidywalne czasy aktualizacji gry:

Tworzenie wieloosobowej gry internetowej .io
Zapłata za to będzie trwała opóźnienie wejściowe przez 100 ms. Jest to niewielkie poświęcenie na rzecz płynnej rozgrywki – większość graczy (zwłaszcza zwykłych graczy) nawet nie zauważy tego opóźnienia. Ludziom znacznie łatwiej jest dostosować się do stałego opóźnienia wynoszącego 100 ms, niż grać z nieprzewidywalnym opóźnieniem.

Możemy także zastosować inną technikę, tzw „prognozowanie po stronie klienta”, który dobrze radzi sobie z redukcją postrzeganych opóźnień, ale nie będzie omawiany w tym poście.

Kolejnym udoskonaleniem, z którego korzystamy, jest interpolacja liniowa. Ze względu na opóźnienie renderowania zazwyczaj jesteśmy co najmniej o jedną aktualizację przed bieżącym czasem w kliencie. Kiedy zadzwoniono getCurrentState(), możemy wykonać interpolacja liniowa pomiędzy aktualizacjami gry tuż przed i po bieżącym czasie w kliencie:

Tworzenie wieloosobowej gry internetowej .io
To rozwiązuje problem szybkości klatek: możemy teraz renderować unikalne klatki z dowolną szybkością klatek!

7.3 Implementowanie ulepszonego stanu klienta

Przykład wdrożenia w src/client/state.js wykorzystuje zarówno opóźnienie renderowania, jak i interpolację liniową, ale nie na długo. Podzielmy kod na dwie części. Oto pierwszy:

stan.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);

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

Pierwszym krokiem jest ustalenie, co currentServerTime(). Jak widzieliśmy wcześniej, każda aktualizacja gry zawiera znacznik czasu serwera. Chcemy użyć opóźnienia renderowania, aby renderować obraz 100 ms za serwerem, ale nigdy nie poznamy aktualnej godziny na serwerze, ponieważ nie wiemy, ile czasu zajęło dotarcie którejkolwiek z aktualizacji. Internet jest nieprzewidywalny, a jego prędkość może się znacznie różnić!

Aby obejść ten problem, możemy zastosować rozsądne przybliżenie: my załóżmy, że pierwsza aktualizacja pojawiła się natychmiast. Gdyby to była prawda, znalibyśmy czas serwera w tym konkretnym momencie! Przechowujemy znacznik czasu serwera w firstServerTimestamp i zachowaj nasze lokalny (klienta) znacznik czasu w tym samym momencie gameStart.

Zaczekaj. Czy nie powinien to być czas serwera = czas klienta? Dlaczego rozróżniamy „sygnał czasowy serwera” i „sygnał czasowy klienta”? To świetne pytanie! Okazuje się, że to nie to samo. Date.now() zwróci różne znaczniki czasu na kliencie i serwerze i zależy to od czynników lokalnych dla tych komputerów. Nigdy nie zakładaj, że znaczniki czasu będą takie same na wszystkich komputerach.

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

Przyjrzyjmy się teraz, jak radzimy sobie z aktualizacjami gier. Po odebraniu z serwera aktualizacji wywoływana jest funkcja processGameUpdate()i zapisujemy nową aktualizację w tablicy gameUpdates. Następnie, aby sprawdzić wykorzystanie pamięci, usuwamy wszystkie stare aktualizacje aktualizacja bazowabo już ich nie potrzebujemy.

Co to jest „podstawowa aktualizacja”? Ten pierwszą aktualizację znajdziemy, cofając się od aktualnego czasu serwera. Pamiętasz ten diagram?

Tworzenie wieloosobowej gry internetowej .io
Aktualizacja gry znajdująca się bezpośrednio po lewej stronie „Czasu renderowania klienta” jest aktualizacją podstawową.

Do czego służy aktualizacja podstawowa? Dlaczego możemy porzucić aktualizacje do wersji bazowej? Aby to rozgryźć, spójrzmy wreszcie rozważyć wdrożenie getCurrentState():

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

Prowadzimy trzy sprawy:

  1. base < 0 oznacza, że ​​nie ma żadnych aktualizacji aż do bieżącego czasu renderowania (patrz implementacja powyżej getBaseUpdate()). Może się to zdarzyć zaraz na początku gry z powodu opóźnienia renderowania. W tym przypadku korzystamy z najnowszej otrzymanej aktualizacji.
  2. base to najnowsza aktualizacja, jaką mamy. Może się to zdarzyć z powodu opóźnienia sieci lub słabego połączenia internetowego. W tym przypadku również korzystamy z najnowszej aktualizacji, jaką mamy.
  3. Mamy aktualizację zarówno przed, jak i po bieżącym czasie renderowania, więc możemy interpolować!

Wszystko, co zostało state.js to implementacja interpolacji liniowej, która jest prostą (ale nudną) matematyką. Jeśli chcesz sam to odkryć, otwórz state.js na Github.

Część 2. Serwer backendowy

W tej części przyjrzymy się backendowi Node.js, który kontroluje nasze Przykład gry .io.

1. Punkt wejścia do serwera

Do zarządzania serwerem WWW wykorzystamy popularny framework WWW dla Node.js o nazwie wyrazić. Zostanie on skonfigurowany przez plik punktu wejścia naszego serwera src/server/server.js:

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

Pamiętasz, że w pierwszej części omawialiśmy Webpack? W tym miejscu użyjemy konfiguracji naszych pakietów internetowych. Wykorzystamy je na dwa sposoby:

  • Использовать webpack-dev-middleware aby automatycznie odbudować nasze pakiety programistyczne, lub
  • Statycznie przenieś folder dist/, do którego Webpack zapisze nasze pliki po kompilacji produkcyjnej.

Kolejne ważne zadanie server.js jest konfiguracja serwera gniazdo.ioktóry właśnie łączy się z serwerem Express:

serwer.js, część 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 pomyślnym nawiązaniu połączenia Socket.io z serwerem konfigurujemy obsługę zdarzeń dla nowego gniazda. Programy obsługi zdarzeń obsługują komunikaty otrzymane od klientów poprzez delegowanie do obiektu singleton game:

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

Tworzymy grę .io, więc potrzebujemy tylko jednej kopii Game („Gra”) – wszyscy gracze grają na tej samej arenie! W następnej sekcji zobaczymy, jak działa ta klasa. Game.

2. Serwery gier

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

Zacznijmy od pierwszego zadania, zarządzania graczami.

gra.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;

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

  // ...
}

W tej grze będziemy identyfikować graczy na podstawie boiska id ich gniazdo Socket.io (jeśli się pomylisz, wróć do server.js). Sam Socket.io przypisuje każdemu gniazdu unikalny numer id, więc nie musimy się tym martwić. zadzwonię do niego Identyfikator gracza.

Mając to na uwadze, przyjrzyjmy się zmiennym instancji w klasie Game:

  • sockets to obiekt, który wiąże identyfikator gracza z gniazdem powiązanym z odtwarzaczem. Dzięki temu mamy stały dostęp do gniazd po identyfikatorach graczy.
  • players to obiekt, który wiąże identyfikator gracza z obiektem code>Player

bullets jest tablicą obiektów Bullet, który nie ma określonej kolejności.
lastUpdateTime to sygnatura czasowa ostatniej aktualizacji gry. Wkrótce zobaczymy, jak zostanie wykorzystany.
shouldSendUpdate jest zmienną pomocniczą. Wkrótce przekonamy się także o jego zastosowaniu.
Metody addPlayer(), removePlayer() и handleInput() nie trzeba wyjaśniać, są używane server.js. Jeśli chcesz odświeżyć pamięć, cofnij się nieco wyżej.

Ostatni wiersz constructor() Р · Р ° РїСѓСЃРєР ° РµС ‚ cykl aktualizacji gry (z częstotliwością 60 aktualizacji/s):

gra.js część 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() zawiera prawdopodobnie najważniejszą część logiki po stronie serwera. Wymieńmy wszystko, co robi, w kolejności:

  1. Oblicza, jak długo dt minęło od ostatniego update().
  2. Odświeża każdy pocisk i w razie potrzeby niszczy go. Implementację tej funkcjonalności zobaczymy później. Na razie wystarczy, że to wiemy bullet.update() zwroty true, jeśli pocisk musi zostać zniszczony (wyszedł z areny).
  3. Aktualizuje każdego gracza i, jeśli to konieczne, wytwarza pocisk. Tę implementację zobaczymy również później - player.update() może zwrócić obiekt Bullet.
  4. Sprawdza kolizje pomiędzy pociskami i graczami applyCollisions(), która zwraca tablicę pocisków, które trafiają graczy. Za każdy zwrócony pocisk zwiększamy wynik gracza, który go wystrzelił (za pomocą player.onDealtDamage()), a następnie usuń pocisk z szyku bullets.
  5. Powiadamia i niszczy wszystkich zabitych graczy.
  6. Wysyła aktualizację gry do wszystkich graczy każda sekunda razy, kiedy dzwoniono update(). Pomaga nam to śledzić zmienną pomocniczą wspomnianą powyżej. shouldSendUpdate. Ponieważ update() wywoływany 60 razy/s, wysyłamy aktualizacje gry 30 razy/s. Zatem, częstotliwość zegara zegar serwera wynosi 30 zegarów/s (mówiliśmy o częstotliwości taktowania w pierwszej części).

Po co wysyłać tylko aktualizacje gier przez czas ? Aby zapisać kanał. 30 aktualizacji gier na sekundę to dużo!

Dlaczego po prostu nie zadzwonić update() 30 razy na sekundę? Aby ulepszyć symulację gry. Częściej tzw update(), tym dokładniejsza będzie symulacja gry. Ale nie daj się ponieść liczbie wyzwań. update(), ponieważ jest to zadanie kosztowne obliczeniowo – wystarczy 60 na sekundę.

Reszta klasy Game składa się z metod pomocniczych używanych w update():

gra.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() całkiem proste — sortuje graczy według punktów, wybiera pierwszą piątkę i zwraca nazwę użytkownika oraz wynik każdego z nich.

createUpdate() użyty w update() do tworzenia aktualizacji gier dystrybuowanych wśród graczy. Jego głównym zadaniem jest wywoływanie metod serializeForUpdate()realizowane na zajęciach Player и Bullet. Pamiętaj, że przekazuje każdemu graczowi tylko dane dotyczące najbliższy gracze i pociski - nie ma potrzeby przesyłania informacji o obiektach gry, które są daleko od gracza!

3. Obiekty gry na serwerze

W naszej grze pociski i gracze są w rzeczywistości bardzo podobni: są to abstrakcyjne, okrągłe, ruchome obiekty w grze. Aby wykorzystać to podobieństwo pomiędzy graczami i pociskami, zacznijmy od zaimplementowania klasy bazowej Object:

obiekt.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,
    };
  }
}

Nie dzieje się tu nic skomplikowanego. Ta klasa będzie dobrym punktem zakotwiczenia dla rozszerzenia. Zobaczymy jak klasa Bullet używa Object:

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

realizacja Bullet bardzo krótki! Dodaliśmy do Object tylko następujące rozszerzenia:

  • Korzystanie z pakietu krótki do losowego generowania id pocisk.
  • Dodanie pola parentIDabyś mógł wyśledzić gracza, który stworzył ten pocisk.
  • Dodanie wartości zwracanej do update(), co jest równe truejeśli pocisk znajduje się poza areną (pamiętasz, że rozmawialiśmy o tym w ostatniej sekcji?).

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

Gracze są bardziej złożeni niż pociski, dlatego w tej klasie należy przechowywać kilka pól więcej. Jego metoda update() wykonuje dużo pracy, w szczególności zwraca nowo utworzony pocisk, jeśli go już nie ma fireCooldown (pamiętasz, że rozmawialiśmy o tym w poprzedniej sekcji?). Rozszerza także metodę serializeForUpdate(), ponieważ w aktualizacji gry musimy uwzględnić dodatkowe pola dla gracza.

Posiadanie klasy bazowej Object - ważny krok, aby uniknąć powtarzania kodu. Na przykład brak zajęć Object każdy obiekt gry musi mieć tę samą implementację distanceTo(), a kopiowanie i wklejanie wszystkich tych implementacji do wielu plików byłoby koszmarem. Staje się to szczególnie ważne w przypadku dużych projektów.kiedy liczba się zwiększa Object klasy rosną.

4. Wykrywanie kolizji

Jedyne, co nam pozostało, to rozpoznać, kiedy pociski trafiły w graczy! Zapamiętaj ten fragment kodu z metody update() w klasie Game:

gra.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),
    );

    // ...
  }
}

Musimy wdrożyć tę metodę applyCollisions(), która zwraca wszystkie pociski, które trafiły graczy. Na szczęście nie jest to takie trudne, ponieważ

  • Wszystkie kolidujące obiekty to okręgi, co jest najprostszym kształtem umożliwiającym wdrożenie wykrywania kolizji.
  • Mamy już metodę distanceTo(), które zaimplementowaliśmy w poprzedniej sekcji zajęć Object.

Tak wygląda nasza implementacja wykrywania kolizji:

kolizje.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 wykrywanie kolizji opiera się na fakcie, że dwa okręgi zderzają się, jeśli odległość między ich środkami jest mniejsza niż suma ich promieni. Oto przypadek, w którym odległość między środkami dwóch okręgów jest dokładnie równa sumie ich promieni:

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

  • Pocisk nie może trafić gracza, który go stworzył. Można to osiągnąć poprzez porównanie bullet.parentID с player.id.
  • Pocisk może trafić tylko raz w ograniczonym przypadku zderzenia wielu graczy w tym samym czasie. Rozwiążemy ten problem za pomocą operatora break: po znalezieniu gracza kolidującego z pociskiem, zatrzymujemy poszukiwania i przechodzimy do następnego pocisku.

koniec

To wszystko! Omówiliśmy wszystko, co musisz wiedzieć, aby stworzyć grę internetową .io. Co dalej? Zbuduj własną grę .io!

Cały przykładowy kod jest open source i jest opublikowany na Github.

Źródło: www.habr.com

Dodaj komentarz