Multiplayer .io Veb Oyununun yaradılması

Multiplayer .io Veb Oyununun yaradılması
2015-ci ildə buraxılıb Agar.io yeni bir janrın banisi oldu oyunlar .ioo vaxtdan bəri populyarlığı artmışdır. Mən şəxsən .io oyunlarının populyarlığının yüksəlişini yaşadım: son üç ildə mən yaşadım bu janrda iki oyun yaratdı və satdı..

Əgər əvvəllər bu oyunları eşitməmisinizsə, bunlar oynamaq asan olan pulsuz multiplayer veb oyunlarıdır (heç bir hesab tələb olunmur). Onlar adətən eyni arenada bir çox rəqib oyunçu ilə qarşılaşırlar. Digər məşhur .io oyunları: Slither.io и Diep.io.

Bu yazıda necə olduğunu araşdıracağıq sıfırdan .io oyunu yaradın. Bunun üçün yalnız Javascript bilikləri kifayət edəcək: sintaksis kimi şeyləri başa düşməlisiniz ES6, açar söz this и Promises. Javascript bilikləriniz mükəmməl olmasa belə, siz hələ də yazının çoxunu başa düşə bilərsiniz.

.io oyun nümunəsi

Öyrənmə yardımı üçün müraciət edəcəyik .io oyun nümunəsi. Oynamağa çalışın!

Multiplayer .io Veb Oyununun yaradılması
Oyun olduqca sadədir: siz başqa oyunçuların olduğu arenada gəmiyə nəzarət edirsiniz. Gəminiz avtomatik olaraq mərmilər atır və siz onların mərmilərindən qaçaraq digər oyunçuları vurmağa çalışırsınız.

1. Layihənin qısa icmalı / strukturu

tövsiyə mənbə kodunu yükləyin nümunə oyun ki, məni izləyə biləsiniz.

Nümunə aşağıdakılardan istifadə edir:

  • Təcili oyunun veb serverini idarə edən ən məşhur Node.js veb çərçivəsidir.
  • socket.io - brauzer və server arasında məlumat mübadiləsi üçün websocket kitabxanası.
  • Veb-paket - modul meneceri. Webpack-dən niyə istifadə etmək barədə oxuya bilərsiniz. burada.

Layihənin qovluq strukturu belə görünür:

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

ictimai/

Hər şey bir qovluqda public/ server tərəfindən statik olaraq təqdim olunacaq. IN public/assets/ layihəmizin istifadə etdiyi şəkilləri ehtiva edir.

src /

Bütün mənbə kodu qovluqdadır src/. Başlıqlar client/ и server/ özləri üçün danışmaq və shared/ həm müştəri, həm də server tərəfindən idxal edilən sabitlər faylını ehtiva edir.

2. Montajlar/layihə parametrləri

Yuxarıda qeyd edildiyi kimi, layihəni qurmaq üçün modul menecerindən istifadə edirik. Veb-paket. Gəlin Webpack konfiqurasiyamıza nəzər salaq:

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

Burada ən vacib sətirlər bunlardır:

  • src/client/index.js Javascript (JS) müştərisinin giriş nöqtəsidir. Webpack buradan başlayacaq və digər idxal edilmiş fayllar üçün rekursiv axtarış aparacaq.
  • Webpack quruluşumuzun çıxış JS-i kataloqda yerləşəcək dist/. Mən bu faylı bizim adlandıracağam js paketi.
  • İstifadə edirik Babel, və xüsusilə konfiqurasiya @babel/preset-env köhnə brauzerlər üçün JS kodunu köçürmək üçün.
  • JS faylları tərəfindən istinad edilən bütün CSS-ləri çıxarmaq və onları bir yerdə birləşdirmək üçün plaqindən istifadə edirik. Mən ona bizim deyəcəyəm css paketi.

Siz qəribə paket fayl adlarını görmüsünüz '[name].[contenthash].ext'. Onlar ehtiva edir fayl adının dəyişdirilməsi Veb paketi: [name] giriş nöqtəsinin adı ilə əvəz olunacaq (bizim vəziyyətimizdə bu game), və [contenthash] faylın məzmununun hash ilə əvəz olunacaq. Biz bunu edirik layihəni hashing üçün optimallaşdırın - brauzerlərə JS paketlərimizi qeyri-müəyyən müddətə keşləmələrini deyə bilərsiniz, çünki paket dəyişirsə, onun fayl adı da dəyişir (dəyişikliklər contenthash). Son nəticə görünüş faylının adı olacaq game.dbeee76e91a97d0c7207.js.

Файл webpack.common.js inkişaf və bitmiş layihə konfiqurasiyalarına idxal etdiyimiz əsas konfiqurasiya faylıdır. Budur bir nümunə inkişaf konfiqurasiyası:

webpack.dev.js

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

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

Səmərəlilik üçün inkişaf prosesində istifadə edirik webpack.dev.js, və keçid edir webpack.prod.jsistehsala yerləşdirərkən paket ölçülərini optimallaşdırmaq.

Yerli ayar

Layihəni yerli maşında quraşdırmağı məsləhət görürəm ki, bu yazıda sadalanan addımları izləyə biləsiniz. Quraşdırma sadədir: əvvəlcə sistem quraşdırılmalıdır Düyün и NPM. Sonra etmək lazımdır

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

və getməyə hazırsınız! İnkişaf serverinə başlamaq üçün sadəcə işə salın

$ npm run develop

və veb brauzerə keçin localhost: 3000. Kod dəyişdikcə inkişaf serveri avtomatik olaraq JS və CSS paketlərini yenidən quracaq - bütün dəyişiklikləri görmək üçün səhifəni yeniləyin!

3. Müştərilərin Giriş Nöqtələri

Gəlin oyun kodunun özünə keçək. Əvvəlcə bizə bir səhifə lazımdır index.html, sayta daxil olarkən brauzer əvvəlcə onu yükləyəcək. Səhifəmiz olduqca sadə olacaq:

index.html

Bir nümunə .io oyunu  OYNAMAQ

Bu kod nümunəsi aydınlıq üçün bir qədər sadələşdirilmişdir və mən də bir çox digər yazı nümunələri ilə eyni şeyi edəcəyəm. Tam kodu həmişə burada görmək olar Github.

Bizdə:

  • HTML5 kətan elementi (<canvas>) oyunu göstərmək üçün istifadə edəcəyik.
  • <link> CSS paketimizi əlavə etmək üçün.
  • <script> Javascript paketimizi əlavə etmək üçün.
  • İstifadəçi adı ilə əsas menyu <input> və PLAY düyməsini (<button>).

Əsas səhifəni yüklədikdən sonra brauzer JS faylının giriş nöqtəsindən başlayaraq Javascript kodunu icra etməyə başlayacaq: 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);
  };
});

Bu mürəkkəb səslənə bilər, lakin burada çox şey yoxdur:

  1. Bir neçə digər JS faylının idxalı.
  2. CSS idxalı (buna görə də Webpack onları bizim CSS paketimizə daxil etməyi bilir).
  3. Başlanğıc connect() server ilə əlaqə yaratmaq və işə salmaq üçün downloadAssets() oyunu göstərmək üçün lazım olan şəkilləri yükləmək üçün.
  4. 3-cü mərhələ başa çatdıqdan sonra əsas menyu göstərilir (playMenu).
  5. "PLAY" düyməsini basmaq üçün idarəedicinin qurulması. Düymə basıldıqda kod oyunu işə salır və serverə oynamağa hazır olduğumuzu bildirir.

Müştəri-server məntiqimizin əsas "əti" fayl tərəfindən idxal edilmiş fayllardadır index.js. İndi onların hamısını qaydasında nəzərdən keçirəcəyik.

4. Müştəri məlumatlarının mübadiləsi

Bu oyunda biz serverlə əlaqə saxlamaq üçün tanınmış kitabxanadan istifadə edirik socket.io. Socket.io-nun yerli dəstəyi var veb rozetkaları, ikitərəfli ünsiyyət üçün yaxşı uyğundur: biz serverə mesaj göndərə bilərik и server eyni əlaqədə bizə mesaj göndərə bilər.

Bir faylımız olacaq src/client/networking.jskim baxacaq hamısı tərəfindən server ilə əlaqə:

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

Bu kod da aydınlıq üçün bir qədər qısaldılmışdır.

Bu faylda üç əsas hərəkət var:

  • Biz serverə qoşulmağa çalışırıq. connectedPromise yalnız əlaqə qurduqda icazə verilir.
  • Bağlantı uğurlu olarsa, biz geri çağırış funksiyalarını qeyd edirik (processGameUpdate() и onGameOver()) serverdən ala biləcəyimiz mesajlar üçün.
  • Biz ixrac edirik play() и updateDirection()ki, digər fayllar onlardan istifadə edə bilsin.

5. Müştərilərin göstərilməsi

Şəkli ekranda göstərməyin vaxtıdır!

…lakin bunu etməzdən əvvəl bunun üçün lazım olan bütün şəkilləri (resursları) endirməliyik. Gəlin resurs meneceri yazaq:

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

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

Resursların idarə edilməsini həyata keçirmək o qədər də çətin deyil! Əsas ideya obyekti saxlamaqdır assets, fayl adının açarını obyektin dəyərinə bağlayacaq Image. Resurs yükləndikdə onu obyektdə saxlayırıq assets gələcəkdə sürətli çıxış üçün. Hər bir fərdi resursun endirilməsinə nə vaxt icazə veriləcək (yəni, bütün resurslar), icazə veririk downloadPromise.

Resursları yüklədikdən sonra göstərməyə başlaya bilərsiniz. Daha əvvəl deyildiyi kimi, bir web səhifəsində çəkmək üçün istifadə edirik HTML5 Canvas (<canvas>). Oyunumuz olduqca sadədir, ona görə də yalnız aşağıdakıları çəkməliyik:

  1. Ümumi məlumat
  2. Oyunçu gəmisi
  3. Oyundakı digər oyunçular
  4. Kabuklar

Budur vacib fraqmentlər src/client/render.js, yuxarıda sadalanan dörd elementi təqdim edir:

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

Bu kod da aydınlıq üçün qısaldılmışdır.

render() bu faylın əsas funksiyasıdır. startRendering() и stopRendering() 60 FPS-də render dövrəsinin aktivləşdirilməsinə nəzarət edin.

Fərdi renderinq köməkçi funksiyalarının konkret tətbiqləri (məs. renderBullet()) o qədər də vacib deyil, amma sadə bir misal:

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

Qeyd edək ki, metoddan istifadə edirik getAsset(), daha əvvəl görüldü asset.js!

Digər renderinq köməkçiləri haqqında öyrənmək istəyirsinizsə, qalanını oxuyun. src/client/render.js.

6. Müştəri daxiletməsi

Oyun etmək vaxtıdır oynana bilən! İdarəetmə sxemi çox sadə olacaq: hərəkət istiqamətini dəyişdirmək üçün siçandan (kompüterdə) istifadə edə və ya ekrana toxuna bilərsiniz (mobil cihazda). Bunu həyata keçirmək üçün qeydiyyatdan keçəcəyik Tədbir dinləyənlər Siçan və Toxunma hadisələri üçün.
Bütün bunların qayğısına qalacaq 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() zəng edən Hadisə Dinləyiciləridir updateDirection() ( networking.js) giriş hadisəsi baş verdikdə (məsələn, siçan hərəkət etdirdikdə). updateDirection() giriş hadisəsini idarə edən və oyun vəziyyətini müvafiq olaraq yeniləyən serverlə mesajlaşmanı idarə edir.

7. Müştəri statusu

Bu bölmə yazının birinci hissəsində ən çətin hissədir. İlk dəfə oxuyanda başa düşmürsənsə ruhdan düşmə! Siz hətta onu keçib daha sonra ona qayıda bilərsiniz.

Müştəri/server kodunu tamamlamaq üçün lazım olan tapmacanın son hissəsidir idi. Client Rendering bölməsindəki kod parçasını xatırlayırsınız?

render.js

import { getCurrentState } from './state';

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

  // Do the rendering
  // ...
}

getCurrentState() müştəridəki oyunun cari vəziyyətini bizə verə bilməlidir zamanın istənilən nöqtəsində serverdən alınan yeniləmələrə əsaslanır. Serverin göndərə biləcəyi oyun yeniləməsinə bir nümunə:

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

Hər oyun yeniləməsi beş eyni sahəni ehtiva edir:

  • t: Bu yeniləmənin nə vaxt yaradıldığını göstərən server vaxt damğası.
  • me: Bu yeniləməni alan oyunçu haqqında məlumat.
  • s: Eyni oyunda iştirak edən digər oyunçular haqqında məlumat toplusu.
  • güllələr: oyundakı mərmilər haqqında məlumat toplusu.
  • afiş: Cari liderlər lövhəsi məlumatları. Bu yazıda onları nəzərdən keçirməyəcəyik.

7.1 Sadəlövh müştəri vəziyyəti

Sadəlövh icra getCurrentState() yalnız ən son alınan oyun yeniləməsinin məlumatlarını birbaşa qaytara bilər.

naive-state.js

let lastGameUpdate = null;

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

export function getCurrentState() {
  return lastGameUpdate;
}

Gözəl və aydın! Ancaq bu qədər sadə olsaydı. Bu tətbiqin problemli olmasının səbəblərindən biri: o, göstərmə çərçivə sürətini serverin saat sürəti ilə məhdudlaşdırır.

Çərçivə Tezliyi: kadrların sayı (yəni zənglər render()) saniyədə və ya FPS. Oyunlar adətən ən azı 60 FPS əldə etməyə çalışır.

Tik dərəcəsi: Serverin müştərilərə oyun yeniləmələrini göndərmə tezliyi. Çox vaxt kadr sürətindən aşağı olur. Oyunumuzda server saniyədə 30 dövrə tezliyi ilə işləyir.

Yalnız oyunun ən son yeniləməsini təqdim etsək, FPS heç vaxt 30-u keçməyəcək, çünki biz heç vaxt serverdən saniyədə 30-dan çox yeniləmə almırıq. Zəng etsək belə render() Saniyədə 60 dəfə, sonra bu zənglərin yarısı eyni şeyi yenidən çəkəcək, mahiyyətcə heç nə etməyəcək. Tətbiqin sadəlövh olması ilə bağlı başqa bir problem də budur gecikmələrə meyllidir. İdeal İnternet sürəti ilə müştəri hər 33 ms (saniyədə 30) oyun yeniləməsini alacaq:

Multiplayer .io Veb Oyununun yaradılması
Təəssüf ki, heç bir şey mükəmməl deyil. Daha real bir şəkil belə olardı:
Multiplayer .io Veb Oyununun yaradılması
Gecikməyə gəldikdə sadəlövh tətbiq praktiki olaraq ən pis haldır. Oyun yeniləməsi 50 ms gecikmə ilə qəbul edilərsə, o zaman müştəri stendləri əlavə 50 ms, çünki o, əvvəlki yeniləmədən hələ də oyun vəziyyətini göstərir. Bunun oyunçu üçün nə qədər narahat olduğunu təsəvvür edə bilərsiniz: ixtiyari əyləc oyunu sarsıdıcı və qeyri-sabit hiss etdirəcək.

7.2 Təkmilləşdirilmiş müştəri vəziyyəti

Biz sadəlövh tətbiqdə bəzi təkmilləşdirmələr edəcəyik. Birincisi, istifadə edirik göstərilməsi gecikməsi 100 ms üçün. Bu o deməkdir ki, müştərinin "cari" vəziyyəti həmişə serverdəki oyunun vəziyyətindən 100 ms geri qalacaq. Məsələn, serverdəki vaxtdırsa 150, sonra müştəri serverin o vaxt olduğu vəziyyəti göstərəcək 50:

Multiplayer .io Veb Oyununun yaradılması
Bu, gözlənilməz oyun yeniləmə vaxtlarından sağ çıxmaq üçün bizə 100 ms bufer verir:

Multiplayer .io Veb Oyununun yaradılması
Bunun əvəzi daimi olacaq giriş gecikməsi 100 ms üçün. Bu, hamar oyun üçün kiçik bir qurbandır - əksər oyunçular (xüsusilə də təsadüfi oyunçular) bu gecikməni belə hiss etməyəcəklər. İnsanlar üçün gözlənilməz gecikmə ilə oynamaqdansa, sabit 100 ms gecikməyə uyğunlaşmaq daha asandır.

adlı başqa bir texnikadan da istifadə edə bilərik müştəri tərəfi proqnozu, qəbul edilən gecikməni azaltmaq üçün yaxşı bir iş görür, lakin bu yazıda əhatə olunmayacaq.

İstifadə etdiyimiz digər təkmilləşdirmədir xətti interpolyasiya. Göstərilmə gecikməsinə görə, biz adətən müştəridə cari vaxtdan ən azı bir yeniləmə irəliləyirik. Zəng edəndə getCurrentState(), icra edə bilərik xətti interpolyasiya müştəridə cari vaxtdan dərhal əvvəl və sonra oyun yeniləmələri arasında:

Multiplayer .io Veb Oyununun yaradılması
Bu, kadr tezliyi problemini həll edir: indi biz istədiyimiz kadr sürətində unikal kadrları göstərə bilərik!

7.3 Təkmil müştəri vəziyyətinin həyata keçirilməsi

Tətbiq nümunəsi src/client/state.js həm render gecikməsindən, həm də xətti interpolyasiyadan istifadə edir, lakin uzun müddət deyil. Kodu iki hissəyə bölək. Budur birincisi:

state.js 1-ci hissə

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

İlk addım nəyin olduğunu anlamaqdır currentServerTime(). Daha əvvəl gördüyümüz kimi, hər oyun yeniləməsinə server vaxt damğası daxildir. Şəkli serverdən 100 ms arxada göstərmək üçün göstərmə gecikməsindən istifadə etmək istəyirik, lakin serverdə cari vaxtı heç vaxt bilməyəcəyik, çünki hər hansı yeniləmənin bizə çatması üçün nə qədər vaxt lazım olduğunu bilmirik. İnternet gözlənilməzdir və sürəti çox fərqli ola bilər!

Bu problemi həll etmək üçün ağlabatan bir təxmindən istifadə edə bilərik: biz ilk yeniləmənin dərhal gəldiyini iddia edin. Əgər bu doğru olsaydı, o zaman server vaxtını bu anda biləcəkdik! Biz serverin vaxt damgasını saxlayırıq firstServerTimestamp və bizim yerli (müştəri) eyni anda vaxt damğası gameStart.

OH gözləyin. Server vaxtı = müştəri vaxtı olmalı deyilmi? Niyə biz "server vaxt damğası" və "müştəri vaxt damğası" arasında fərq qoyuruq? Bu əla sualdır! Məlum oldu ki, onlar eyni şey deyil. Date.now() müştəri və serverdə müxtəlif vaxt damğalarını qaytaracaq və bu, bu maşınlar üçün yerli amillərdən asılıdır. Zaman ştamplarının bütün maşınlarda eyni olacağını heç vaxt düşünməyin.

İndi nə etdiyini başa düşürük currentServerTime(): qayıdır cari render vaxtının server vaxt damğası. Başqa sözlə, bu serverin cari vaxtıdır (firstServerTimestamp <+ (Date.now() - gameStart)) mənfi göstərmə gecikməsi (RENDER_DELAY).

İndi gəlin oyun yeniləmələrini necə idarə etdiyimizə nəzər salaq. Yeniləmə serverindən alındıqda, çağırılır processGameUpdate()və biz yeni yeniləməni massivdə saxlayırıq gameUpdates. Sonra, yaddaş istifadəsini yoxlamaq üçün əvvəllər bütün köhnə yeniləmələri silirik baza yeniləməsiçünki onlara daha ehtiyacımız yoxdur.

"Əsas yeniləmə" nədir? Bu serverin cari vaxtından geriyə doğru hərəkət edərək tapdığımız ilk yeniləmə. Bu diaqramı xatırlayırsınız?

Multiplayer .io Veb Oyununun yaradılması
"Client Render Time"ın birbaşa solunda olan oyun yeniləməsi əsas yeniləmədir.

Əsas yeniləmə nə üçün istifadə olunur? Niyə yeniləmələri bazaya endirə bilərik? Bunu anlamaq üçün gəlin nəhayət həyata keçirilməsini nəzərdən keçirin getCurrentState():

state.js 2-ci hissə

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

Biz üç halda baxırıq:

  1. base < 0 o deməkdir ki, cari render vaxtına qədər heç bir yeniləmə yoxdur (yuxarıda tətbiqə baxın getBaseUpdate()). Bu, göstərmə gecikməsi səbəbindən oyunun başlanğıcında baş verə bilər. Bu halda biz alınan ən son yeniləmədən istifadə edirik.
  2. base əlimizdə olan ən son yeniləmədir. Bunun səbəbi şəbəkə gecikməsi və ya zəif İnternet bağlantısı ola bilər. Bu halda biz də əlimizdə olan ən son yeniləmədən istifadə edirik.
  3. Cari göstərmə vaxtından əvvəl və sonra yeniləməmiz var, buna görə də edə bilərik interpolyasiya edin!

İçəridə qalanların hamısı state.js sadə (lakin darıxdırıcı) riyaziyyat olan xətti interpolyasiyanın həyata keçirilməsidir. Onu özünüz araşdırmaq istəyirsinizsə, açın state.js haqqında Github.

Hissə 2. Backend server

Bu hissədə biz idarə etdiyimiz Node.js backendinə nəzər salacağıq .io oyun nümunəsi.

1. Server Giriş Nöqtəsi

Veb serveri idarə etmək üçün Node.js adlı məşhur veb çərçivədən istifadə edəcəyik Təcili. O, server giriş nöqtəsi faylımız tərəfindən konfiqurasiya ediləcək src/server/server.js:

server.js hissə 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}`);

Yadınızdadırsa, birinci hissədə Webpack-i müzakirə etdik? Webpack konfiqurasiyalarımızdan istifadə edəcəyimiz yer budur. Onları iki şəkildə istifadə edəcəyik:

  • Istifadə webpack-dev-orta proqram inkişaf paketlərimizi avtomatik olaraq yenidən qurmaq və ya
  • qovluğu statik olaraq köçürün dist/, istehsal qurulduqdan sonra Webpack fayllarımızı yazacaq.

Başqa bir vacib vəzifə server.js server qurmaqdır socket.ioyalnız Express serverinə qoşulur:

server.js hissə 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);
});

Serverə socket.io bağlantısını uğurla qurduqdan sonra biz yeni rozetka üçün hadisə idarəedicilərini quraşdırdıq. Hadisə idarəçiləri müştərilərdən alınan mesajları singleton obyektinə həvalə etməklə idarə edir game:

server.js hissə 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);
}

Biz .io oyunu yaradırıq, ona görə də bizə yalnız bir nüsxə lazımdır Game ("Oyun") - bütün oyunçular eyni arenada oynayır! Növbəti hissədə bu sinfin necə işlədiyini görəcəyik. Game.

2. Oyun serverləri

Sinif Game server tərəfində ən vacib məntiqi ehtiva edir. Onun iki əsas vəzifəsi var: oyunçu rəhbərliyi и oyun simulyasiyası.

İlk tapşırıqdan, oyunçu idarəçiliyindən başlayaq.

game.js 1 hissə

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

  // ...
}

Bu oyunda biz oyunçuları meydançaya görə müəyyən edəcəyik id onların socket.io yuvası (əgər çaşqınsınızsa, onda geri qayıdın server.js). Socket.io özü hər bir yuvaya unikal təyin edir idona görə də bu barədə narahat olmaq lazım deyil. Mən ona zəng edəcəm Oyunçu ID.

Bunu nəzərə alaraq, bir sinifdəki nümunə dəyişənlərini araşdıraq Game:

  • sockets oyunçu identifikatorunu pleyer ilə əlaqəli yuvaya bağlayan obyektdir. Bu, bizə daimi olaraq onların oyunçu identifikatorları ilə rozetkalara daxil olmaq imkanı verir.
  • players oyunçu identifikatorunu kod>Player obyektinə bağlayan obyektdir

bullets obyektlər massividir Bullet, hansının müəyyən bir sırası yoxdur.
lastUpdateTime oyunun sonuncu dəfə yenilənməsinin vaxt möhürüdür. Onun necə istifadə edildiyini tezliklə görəcəyik.
shouldSendUpdate köməkçi dəyişəndir. Onun istifadəsini də tezliklə görəcəyik.
Metodlar addPlayer(), removePlayer() и handleInput() izah etməyə ehtiyac yoxdur, istifadə olunur server.js. Yaddaşınızı təzələmək lazımdırsa, bir az yuxarı geri qayıdın.

Son xətt constructor() başlayır yeniləmə dövrü oyunlar (60 yeniləmə / s tezliyi ilə):

game.js 2 hissə

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

  // ...
}

üsul update() server tərəfi məntiqinin bəlkə də ən vacib hissəsini ehtiva edir. Budur, ardıcıl olaraq:

  1. Nə qədər uzun olduğunu hesablayır dt sonuncudan keçdi update().
  2. Hər bir mərmi təzələyir və lazım gələrsə məhv edir. Bu funksiyanın həyata keçirilməsini daha sonra görəcəyik. Hələlik bunu bilməyimiz kifayətdir bullet.update() qayıdır truemərmi məhv edilməlidirsə (arenadan çıxdı).
  3. Hər bir oyunçunu yeniləyir və lazım olduqda bir mərmi yaradır. Bu tətbiqi daha sonra görəcəyik - player.update() obyekti qaytara bilər Bullet.
  4. Mərmilər və oyunçular arasında toqquşma olub olmadığını yoxlayır applyCollisions(), oyunçulara dəyən bir sıra mərmiləri qaytarır. Geri qaytarılan hər bir mərmi üçün onu atan oyunçunun xallarını artırırıq ( player.onDealtDamage()) və sonra mərmi massivdən çıxarın bullets.
  5. Bütün öldürülən oyunçuları xəbərdar edir və məhv edir.
  6. Bütün oyunçulara oyun yeniləməsini göndərir hər saniyə çağırılan vaxtlar update(). Bu, yuxarıda qeyd olunan köməkçi dəyişəni izləməyə kömək edir. shouldSendUpdate... Çünki update() 60 dəfə/s çağırılır, biz oyun yeniləmələrini 30 dəfə/s göndəririk. Beləliklə, saat tezliyi server saatı 30 saat/s təşkil edir (biz birinci hissədə saat sürətləri haqqında danışdıq).

Niyə yalnız oyun yeniləmələrini göndərin zaman vasitəsilə ? Kanalı saxlamaq üçün. Saniyədə 30 oyun yeniləməsi çox şeydir!

Niyə sadəcə zəng etmirsən update() Saniyədə 30 dəfə? Oyun simulyasiyasını yaxşılaşdırmaq üçün. Daha tez-tez çağırılır update(), oyun simulyasiyası daha dəqiq olacaq. Ancaq çətinliklərin sayı ilə çox məşğul olmayın. update(), çünki bu, hesablama baxımından bahalı bir işdir - saniyədə 60 kifayətdir.

Sinfin qalan hissəsi Game -də istifadə olunan köməkçi üsullardan ibarətdir update():

game.js 3 hissə

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() olduqca sadə - o, oyunçuları xala görə sıralayır, ilk beşliyi götürür və hər biri üçün istifadəçi adını və xalını qaytarır.

createUpdate() -də istifadə olunur update() oyunçulara paylanan oyun yeniləmələri yaratmaq. Onun əsas vəzifəsi metodları çağırmaqdır serializeForUpdate()siniflər üçün həyata keçirilir Player и Bullet. Qeyd edək ki, o, yalnız hər bir oyunçuya məlumat ötürür ən yaxın oyunçular və mərmilər - oyunçudan uzaq olan oyun obyektləri haqqında məlumat ötürməyə ehtiyac yoxdur!

3. Serverdəki oyun obyektləri

Bizim oyunumuzda mərmilər və oyunçular əslində çox oxşardırlar: onlar mücərrəd, yuvarlaq, daşınan oyun obyektləridir. Oyunçular və mərmilər arasındakı bu oxşarlıqdan istifadə etmək üçün əsas sinfi tətbiq etməklə başlayaq Object:

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

Burada mürəkkəb bir şey yoxdur. Bu sinif genişləndirmə üçün yaxşı bir lövbər nöqtəsi olacaqdır. Görək sinif necədir Bullet istifadə edir 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;
  }
}

Tətbiq Bullet çox qısa! -a əlavə etdik Object yalnız aşağıdakı uzantılar:

  • Paketdən istifadə qısa təsadüfi nəsil üçün id mərmi.
  • Sahənin əlavə edilməsi parentIDki, bu mərmi yaradan oyunçunu izləyə biləsiniz.
  • Qaytarma dəyərinin əlavə edilməsi update(), bərabərdir truemərmi arenadan kənardadırsa (bu barədə sonuncu bölmədə danışdığımızı xatırlayırsınız?).

davam edə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,
    };
  }
}

Oyunçular mərmilərdən daha mürəkkəbdir, buna görə də bu sinifdə bir neçə daha çox sahə saxlanmalıdır. Onun metodu update() çox iş görür, xüsusən də yeni yaradılmış mərmi qalmayıbsa qaytarır fireCooldown (Bu barədə əvvəlki bölmədə danışdığımızı xatırlayırsınız?). Həm də metodu genişləndirir serializeForUpdate(), çünki oyun yeniləməsində oyunçu üçün əlavə sahələr daxil etməliyik.

Baza sinfinə sahib olmaq Object - kodun təkrarlanmasının qarşısını almaq üçün vacib bir addım. Məsələn, sinif yoxdur Object hər bir oyun obyekti eyni icraya malik olmalıdır distanceTo(), və bütün bu tətbiqləri birdən çox fayl arasında kopyalayıb yapışdırmaq kabus olardı. Bu, böyük layihələr üçün xüsusilə vacib olur.sayı genişləndikdə Object siniflər böyüyür.

4. Toqquşmanın aşkarlanması

Bizə qalan tək şey mərmilərin oyunçulara nə vaxt dəydiyini bilməkdir! Metoddan bu kod parçasını yadda saxlayın update() sinifdə 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),
    );

    // ...
  }
}

Biz metodu tətbiq etməliyik applyCollisions(), oyunçulara dəyən bütün mərmiləri qaytarır. Xoşbəxtlikdən, bunu etmək o qədər də çətin deyil, çünki

  • Bütün toqquşan obyektlər dairələrdir və bu, toqquşma aşkarlanmasını həyata keçirmək üçün ən sadə formadır.
  • Artıq bir üsulumuz var distanceTo(), biz bunu əvvəlki bölmədə sinifdə həyata keçirdik Object.

Toqquşma aşkarlama tətbiqimiz belə görünür:

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

Bu sadə toqquşma aşkarlanması faktına əsaslanır mərkəzləri arasındakı məsafə onların radiuslarının cəmindən az olarsa, iki dairə toqquşur. Budur, iki dairənin mərkəzləri arasındakı məsafə onların radiuslarının cəminə tam bərabərdir:

Multiplayer .io Veb Oyununun yaradılması
Burada nəzərə alınmalı daha bir neçə aspekt var:

  • Mərmi onu yaradan oyunçuya dəyməməlidir. Buna müqayisə etməklə nail olmaq olar bullet.parentID с player.id.
  • Birdən çox oyunçunun eyni anda toqquşması ilə məhdudlaşdıqda mərmi yalnız bir dəfə vurulmalıdır. Operatordan istifadə edərək bu problemi həll edəcəyik break: mərmi ilə toqquşan oyunçu tapılan kimi axtarışı dayandırıb növbəti mərmiyə keçirik.

Son

Hamısı budur! .io veb oyunu yaratmaq üçün bilməli olduğunuz hər şeyi əhatə etdik. Sonra nə var? Öz .io oyununuzu yaradın!

Bütün nümunə kodu açıq mənbədir və burada yerləşdirilir Github.

Mənbə: www.habr.com

Добавить комментарий