Multiplayer .io veb-o'yinini yaratish

Multiplayer .io veb-o'yinini yaratish
2015 yilda chiqarilgan Agar.io yangi janrning asoschisiga aylandi o'yinlar .ioo'shandan beri mashhurligi oshgan. Men shaxsan .io o'yinlarining mashhurligi oshishini boshdan kechirdim: so'nggi uch yil ichida men shunday bo'ldim ushbu janrdagi ikkita o'yinni yaratdi va sotdi..

Agar siz ilgari bu oʻyinlar haqida eshitmagan boʻlsangiz, bular oʻynash oson boʻlgan bepul koʻp oʻyinchi veb-oʻyinlardir (hech qanday hisob talab qilinmaydi). Ular odatda bitta maydonda ko'plab raqib o'yinchilariga duch kelishadi. Boshqa mashhur .io o'yinlari: Slither.io и Diep.io.

Ushbu postda biz buni qanday qilishni o'rganamiz noldan .io o'yinini yarating. Buning uchun faqat Javascript-ni bilish etarli bo'ladi: siz sintaksis kabi narsalarni tushunishingiz kerak ES6, kalit so'z this и va'dalar. Javascript bo'yicha bilimingiz mukammal bo'lmasa ham, siz postning ko'p qismini tushunishingiz mumkin.

.io o'yiniga misol

O'rganish yordami uchun biz murojaat qilamiz .io o'yiniga misol. Uni o'ynashga harakat qiling!

Multiplayer .io veb-o'yinini yaratish
O'yin juda oddiy: siz boshqa o'yinchilar bo'lgan arenada kemani boshqarasiz. Sizning kemangiz avtomatik ravishda snaryadlarni o'qqa tutadi va siz boshqa o'yinchilarni ularning snaryadlaridan qochib ularni urishga harakat qilasiz.

1. Loyihaning qisqacha ko'rinishi / tuzilishi

Men tavsiya qilaman manba kodini yuklab oling misol o'yin, shuning uchun siz meni kuzatib mumkin.

Misolda quyidagilar qo'llaniladi:

  • ifoda o'yinning veb-serverini boshqaradigan eng mashhur Node.js veb-ramkasidir.
  • socket.io - brauzer va server o'rtasida ma'lumot almashish uchun veb-soket kutubxonasi.
  • Veb-paket - modul menejeri. Nima uchun Webpackdan foydalanish haqida o'qishingiz mumkin. shu yerda.

Loyiha katalogining tuzilishi quyidagicha ko'rinadi:

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

tomoshabinlar/

Hammasi papkada public/ server tomonidan statik ravishda yuboriladi. IN public/assets/ loyihamiz tomonidan ishlatiladigan rasmlarni o'z ichiga oladi.

src /

Barcha manba kodi papkada src/. Sarlavhalar client/ и server/ o'zlari uchun gapirish va shared/ mijoz va server tomonidan import qilinadigan doimiy faylni o'z ichiga oladi.

2. Assambleyalar/loyiha sozlamalari

Yuqorida aytib o'tilganidek, biz loyihani qurish uchun modul menejeridan foydalanamiz. Veb-paket. Keling, veb-paket konfiguratsiyasini ko'rib chiqaylik:

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

Bu erda eng muhim qatorlar:

  • src/client/index.js Javascript (JS) mijozining kirish nuqtasidir. Webpack shu yerdan boshlanadi va boshqa import qilingan fayllarni rekursiv izlaydi.
  • Bizning Webpack konstruktsiyamizning chiqish JS katalogida joylashgan bo'ladi dist/. Men bu faylni o'zimizniki deb nomlayman js paketi.
  • Biz foydalanamiz Bobil, va xususan, konfiguratsiya @babel/preset-env eski brauzerlar uchun JS kodimizni o'tkazish uchun.
  • Biz JS fayllari tomonidan havola qilingan barcha CSS-larni ajratib olish va ularni bir joyda birlashtirish uchun plagindan foydalanmoqdamiz. Men uni bizning css to'plami.

Siz g'alati paket fayl nomlarini ko'rgan bo'lishingiz mumkin '[name].[contenthash].ext'. Ular o'z ichiga oladi fayl nomini almashtirish Veb-paket: [name] kiritish nuqtasi nomi bilan almashtiriladi (bizning holatda, bu game), va [contenthash] fayl mazmuni xeshi bilan almashtiriladi. Biz buni qilamiz loyihani xeshlash uchun optimallashtirish - brauzerlarga JS paketlarimizni cheksiz muddatga keshlashni aytishingiz mumkin, chunki agar paket o'zgartirilsa, uning fayl nomi ham o'zgaradi (o'zgarishlar contenthash). Yakuniy natija ko'rish faylining nomi bo'ladi game.dbeee76e91a97d0c7207.js.

Fayl webpack.common.js biz ishlab chiqish va tugallangan loyiha konfiguratsiyalariga import qiladigan asosiy konfiguratsiya faylidir. Rivojlanish konfiguratsiyasiga misol:

webpack.dev.js

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

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

Samaradorlik uchun biz ishlab chiqish jarayonida foydalanamiz webpack.dev.js, va ga oʻtadi webpack.prod.jsishlab chiqarishga joylashtirishda paket o'lchamlarini optimallashtirish.

Mahalliy sozlash

Loyihani mahalliy mashinaga o'rnatishni tavsiya qilaman, shuning uchun siz ushbu postda keltirilgan qadamlarni bajarishingiz mumkin. O'rnatish oddiy: birinchi navbatda, tizim o'rnatilgan bo'lishi kerak Node и NPM. Keyinchalik qilish kerak

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

va siz borishga tayyormiz! Rivojlanish serverini ishga tushirish uchun shunchaki ishga tushiring

$ npm run develop

va veb-brauzerga o'ting localhost: 3000. Kod o'zgarganda ishlab chiqish serveri avtomatik ravishda JS va CSS paketlarini qayta tiklaydi - barcha o'zgarishlarni ko'rish uchun sahifani yangilang!

3. Mijozning kirish nuqtalari

Keling, o'yin kodining o'ziga tushaylik. Avval bizga sahifa kerak index.html, saytga tashrif buyurganingizda, brauzer avval uni yuklaydi. Bizning sahifamiz juda oddiy bo'ladi:

index.html

io o'yiniga misol  O‘YNA

Ushbu kod misoli ravshanlik uchun biroz soddalashtirilgan va men boshqa ko'plab post misollari bilan ham xuddi shunday qilaman. To'liq kodni har doim quyidagi manzilda ko'rish mumkin Github.

Bizda bor:

  • HTML5 kanvas elementi (<canvas>) biz o'yinni ko'rsatish uchun foydalanamiz.
  • <link> CSS paketimizni qo'shish uchun.
  • <script> Javascript paketimizni qo'shish uchun.
  • Foydalanuvchi nomi bilan asosiy menyu <input> va PLAY tugmasi (<button>).

Bosh sahifani yuklagandan so'ng, brauzer JS faylining kirish nuqtasidan boshlab Javascript kodini bajarishni boshlaydi: 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 murakkab tuyulishi mumkin, ammo bu erda ko'p narsa bo'lmaydi:

  1. Bir nechta boshqa JS fayllarni import qilish.
  2. CSS importi (shuning uchun Webpack ularni bizning CSS paketimizga kiritishni biladi).
  3. Ishga tushish connect() server bilan aloqa o'rnatish va ishga tushirish downloadAssets() o'yinni ko'rsatish uchun kerakli rasmlarni yuklab olish uchun.
  4. 3-bosqichni tugatgandan so'ng asosiy menyu ko'rsatiladi (playMenu).
  5. "PLAY" tugmasini bosish uchun ishlov beruvchini sozlash. Tugma bosilganda, kod o'yinni ishga tushiradi va serverga o'ynashga tayyor ekanligimizni aytadi.

Mijoz-server mantiqimizning asosiy "go'shti" fayl tomonidan import qilingan fayllarda index.js. Endi biz ularning barchasini tartibda ko'rib chiqamiz.

4. Mijoz ma'lumotlarini almashish

Ushbu o'yinda biz server bilan muloqot qilish uchun taniqli kutubxonadan foydalanamiz socket.io. Socket.io-da mahalliy yordam mavjud Veb -rozetkalar, ular ikki tomonlama aloqa uchun juda mos keladi: biz serverga xabar yuborishimiz mumkin и server bir xil ulanishda bizga xabar yuborishi mumkin.

Bizda bitta fayl bo'ladi src/client/networking.jskim g'amxo'rlik qiladi hamma server bilan aloqa:

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

Aniqlik uchun ushbu kod ham biroz qisqartirilgan.

Ushbu faylda uchta asosiy harakat mavjud:

  • Biz serverga ulanishga harakat qilyapmiz. connectedPromise faqat aloqa o'rnatganimizda ruxsat etiladi.
  • Agar ulanish muvaffaqiyatli bo'lsa, biz qayta qo'ng'iroq qilish funktsiyalarini ro'yxatdan o'tkazamiz (processGameUpdate() и onGameOver()) serverdan olishimiz mumkin bo'lgan xabarlar uchun.
  • Eksport qilamiz play() и updateDirection()boshqa fayllar ulardan foydalanishi uchun.

5. Client Rendering

Rasmni ekranda ko'rsatish vaqti keldi!

…lekin buni amalga oshirishdan oldin, buning uchun zarur bo'lgan barcha rasmlarni (resurslarni) yuklab olishimiz kerak. Keling, resurs menejerini yozamiz:

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

Resurslarni boshqarishni amalga oshirish unchalik qiyin emas! Asosiy g'oya ob'ektni saqlashdir assets, bu fayl nomining kalitini ob'ekt qiymatiga bog'laydi Image. Resurs yuklanganda, biz uni ob'ektda saqlaymiz assets kelajakda tezkor kirish uchun. Har bir alohida resursni qachon yuklab olishga ruxsat beriladi (ya'ni, hamma resurslar), biz ruxsat beramiz downloadPromise.

Resurslarni yuklab olgandan so'ng, siz renderlashni boshlashingiz mumkin. Yuqorida aytib o'tilganidek, biz veb-sahifada chizish uchun foydalanamiz HTML5 Canvas (<canvas>). Bizning o'yinimiz juda oddiy, shuning uchun biz faqat quyidagilarni chizishimiz kerak:

  1. fon
  2. O'yinchi kemasi
  3. O'yindagi boshqa o'yinchilar
  4. chig'anoqlar

Mana muhim parchalar src/client/render.js, yuqorida sanab o'tilgan to'rtta elementni ko'rsatadi:

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

Ushbu kod ham aniqlik uchun qisqartirilgan.

render() bu faylning asosiy vazifasi hisoblanadi. startRendering() и stopRendering() 60 FPS da render tsiklining faollashuvini boshqaring.

Individual renderlash yordamchi funksiyalarining aniq amalga oshirilishi (masalan, renderBullet()) unchalik muhim emas, lekin bu erda bitta oddiy misol:

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

E'tibor bering, biz usuldan foydalanamiz getAsset(), ilgari ko'rilgan asset.js!

Agar siz boshqa renderlash yordamchilari haqida bilmoqchi bo'lsangiz, qolganlarini o'qing. src/client/render.js.

6. Mijoz kiritish

O'yin o'tkazish vaqti keldi o'ynash mumkin! Boshqarish sxemasi juda oddiy bo'ladi: harakat yo'nalishini o'zgartirish uchun siz sichqonchani (kompyuterda) ishlatishingiz yoki ekranga (mobil qurilmada) tegishingiz mumkin. Buni amalga oshirish uchun biz ro'yxatdan o'tamiz Voqealar tinglovchilari Sichqoncha va teginish hodisalari uchun.
Bularning barchasiga g'amxo'rlik qiladi 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() qo'ng'iroq qiladigan Voqealar tinglovchilari updateDirection() ( networking.js) kiritish hodisasi sodir bo'lganda (masalan, sichqonchani harakatlantirganda). updateDirection() server bilan xabar almashishni amalga oshiradi, u kirish hodisasini boshqaradi va shunga mos ravishda o'yin holatini yangilaydi.

7. Mijoz holati

Ushbu bo'lim postning birinchi qismida eng qiyin. Agar uni birinchi marta o'qiganingizda tushunmasangiz, tushkunlikka tushmang! Siz hatto uni o'tkazib yuborishingiz va keyinroq qaytishingiz mumkin.

Mijoz/server kodini to'ldirish uchun zarur bo'lgan jumboqning oxirgi qismi Davlat. Mijozlarni ko'rsatish bo'limidagi kod parchasini eslaysizmi?

render.js

import { getCurrentState } from './state';

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

  // Do the rendering
  // ...
}

getCurrentState() mijozdagi o'yinning hozirgi holatini bizga bera olishi kerak istalgan vaqtda serverdan olingan yangilanishlar asosida. Server yuborishi mumkin bo'lgan o'yin yangilanishiga misol:

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

Har bir o'yin yangilanishi beshta bir xil maydonlarni o'z ichiga oladi:

  • t: Ushbu yangilanish qachon yaratilganligini bildiruvchi server vaqt tamg'asi.
  • me: Ushbu yangilanishni olgan o'yinchi haqida ma'lumot.
  • boshqalar: Xuddi shu o'yinda ishtirok etayotgan boshqa o'yinchilar haqida ma'lumotlar to'plami.
  • o'qlar: o'yindagi snaryadlar haqida ma'lumotlar to'plami.
  • Etakchilik: Joriy peshqadamlar jadvali maʼlumotlari. Ushbu postda biz ularni ko'rib chiqmaymiz.

7.1 Oddiy mijoz holati

Oddiy amalga oshirish getCurrentState() faqat oxirgi olingan o'yin yangilanishi ma'lumotlarini to'g'ridan-to'g'ri qaytarishi mumkin.

naive-state.js

let lastGameUpdate = null;

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

export function getCurrentState() {
  return lastGameUpdate;
}

Yaxshi va aniq! Ammo bu juda oddiy bo'lsa edi. Ushbu amaliyotning muammoli sabablaridan biri: u renderlash kvadrat tezligini serverning soat tezligiga cheklaydi.

Kadr tezligi: kadrlar soni (masalan, qo'ng'iroqlar render()soniyada ) yoki FPS. O'yinlar odatda kamida 60 FPS ga erishishga intiladi.

Belgilanish darajasi: Server o'yin yangilanishlarini mijozlarga yuborish chastotasi. Ko'pincha kvadrat tezligidan past bo'ladi. Bizning o'yinimizda server sekundiga 30 tsikl chastotasida ishlaydi.

Agar biz o'yinning so'nggi yangilanishini taqdim qilsak, FPS hech qachon 30 dan oshmaydi, chunki biz hech qachon serverdan soniyada 30 dan ortiq yangilanish olmaymiz. Qo'ng'iroq qilsak ham render() Bir soniyada 60 marta, keyin bu qo'ng'iroqlarning yarmi xuddi shu narsani qayta chizadi, aslida hech narsa qilmaydi. Sodda amalga oshirish bilan bog'liq yana bir muammo shundaki, u kechikishlarga moyil. Ideal Internet tezligi bilan mijoz har 33 ms (sekundiga 30) o'yin yangilanishini oladi:

Multiplayer .io veb-o'yinini yaratish
Afsuski, hech narsa mukammal emas. Haqiqiyroq rasm quyidagicha bo'ladi:
Multiplayer .io veb-o'yinini yaratish
Kechikish haqida gap ketganda, sodda amalga oshirish amalda eng yomon holatdir. Agar o'yin yangilanishi 50 ms kechikish bilan qabul qilinsa, u holda mijozlar stendlari qo'shimcha 50 ms, chunki u avvalgi yangilanishdagi o'yin holatini ko'rsatmoqda. Bu o'yinchi uchun qanchalik noqulay ekanligini tasavvur qilishingiz mumkin: o'zboshimchalik bilan tormozlash o'yinni chalkash va beqaror qiladi.

7.2 Mijozning holati yaxshilandi

Biz sodda amalga oshirish uchun ba'zi yaxshilanishlar qilamiz. Birinchidan, biz foydalanamiz ko'rsatish kechikishi 100 ms uchun. Bu shuni anglatadiki, mijozning "joriy" holati har doim serverdagi o'yin holatidan 100 ms ga orqada qoladi. Misol uchun, agar serverdagi vaqt bo'lsa 150, keyin mijoz o'sha paytda server bo'lgan holatni ko'rsatadi 50:

Multiplayer .io veb-o'yinini yaratish
Bu bizga oldindan aytib bo'lmaydigan o'yin yangilanish vaqtlaridan omon qolish uchun 100 ms buferni beradi:

Multiplayer .io veb-o'yinini yaratish
Buning uchun to'lov doimiy bo'ladi kirish kechikishi 100 ms uchun. Bu silliq o'yin uchun kichik qurbonlik - aksariyat o'yinchilar (ayniqsa oddiy o'yinchilar) bu kechikishni sezmaydilar ham. Odamlar uchun oldindan aytib bo'lmaydigan kechikish bilan o'ynashdan ko'ra doimiy 100 ms kechikishga moslashish osonroq.

deb nomlangan boshqa texnikadan ham foydalanishimiz mumkin mijoz tomonidan bashorat qilish, bu sezilgan kechikishni kamaytirish uchun yaxshi ish qiladi, ammo bu postda ko'rib chiqilmaydi.

Biz foydalanadigan yana bir yaxshilanish chiziqli interpolyatsiya. Renderlash kechikishi tufayli biz odatda mijozning joriy vaqtidan kamida bir marta yangilanamiz. Qo'ng'iroq qilganda getCurrentState(), biz bajara olamiz chiziqli interpolyatsiya mijozdagi joriy vaqtdan oldin va keyin o'yin yangilanishlari orasida:

Multiplayer .io veb-o'yinini yaratish
Bu kadr tezligi muammosini hal qiladi: endi biz istalgan kvadrat tezligida noyob kadrlarni ko'rsatishimiz mumkin!

7.3 Kengaytirilgan mijoz holatini amalga oshirish

Amalga oshirish misolida src/client/state.js render kechikishi va chiziqli interpolyatsiyadan foydalanadi, lekin uzoq vaqt emas. Keling, kodni ikki qismga ajratamiz. Mana birinchisi:

state.js 1-qism

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

Birinchi qadam nima ekanligini aniqlashdir currentServerTime(). Yuqorida ko'rganimizdek, har bir o'yin yangilanishi server vaqt tamg'asini o'z ichiga oladi. Biz tasvirni serverdan 100 ms orqada ko'rsatish uchun render kechikishidan foydalanmoqchimiz, lekin biz serverdagi joriy vaqtni hech qachon bilmaymiz, chunki birorta yangilanish bizga yetib borishi uchun qancha vaqt ketganini bila olmaymiz. Internetni oldindan aytib bo'lmaydi va uning tezligi juda katta farq qilishi mumkin!

Ushbu muammoni hal qilish uchun biz oqilona taxmindan foydalanishimiz mumkin: biz birinchi yangilanish bir zumda kelgandek ko'ring. Agar bu to'g'ri bo'lsa, biz server vaqtini aynan shu vaqtda bilib olardik! Biz server vaqt tamg'asini saqlaymiz firstServerTimestamp va bizning mahalliy (mijoz) vaqt tamg'asi bir vaqtning o'zida gameStart.

Oh, kuting. Server vaqti = mijoz vaqti bo'lishi kerak emasmi? Nima uchun biz "server vaqt tamg'asi" va "mijoz vaqt tamg'asi" ni ajratamiz? Bu ajoyib savol! Ma'lum bo'lishicha, ular bir xil narsa emas. Date.now() mijoz va serverda turli vaqt belgilarini qaytaradi va bu ushbu mashinalar uchun mahalliy omillarga bog'liq. Hech qachon vaqt belgilari barcha mashinalarda bir xil bo'ladi deb o'ylamang.

Endi biz nima ekanligini tushunamiz currentServerTime(): qaytadi joriy render vaqtining server vaqt tamg'asi. Boshqacha qilib aytganda, bu serverning joriy vaqti (firstServerTimestamp <+ (Date.now() - gameStart)) minus render kechikishi (RENDER_DELAY).

Keling, o'yin yangilanishlarini qanday boshqarishimizni ko'rib chiqaylik. Yangilash serveridan olinganida, u chaqiriladi processGameUpdate()va biz yangi yangilanishni massivga saqlaymiz gameUpdates. Keyin, xotiradan foydalanishni tekshirish uchun biz oldingi barcha eski yangilanishlarni olib tashlaymiz bazani yangilashchunki bizga ular endi kerak emas.

"Asosiy yangilanish" nima? Bu serverning joriy vaqtidan orqaga siljish orqali topadigan birinchi yangilanish. Ushbu diagramma esingizdami?

Multiplayer .io veb-o'yinini yaratish
To'g'ridan-to'g'ri "Client Render Time" ning chap tomonidagi o'yin yangilanishi asosiy yangilanishdir.

Asosiy yangilanish nima uchun ishlatiladi? Nega biz yangilanishlarni asosiy darajaga olib tashlashimiz mumkin? Buni tushunish uchun keling nihoyat amalga oshirishni ko'rib chiqing getCurrentState():

state.js 2-qism

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 uchta holatni ko'rib chiqamiz:

  1. base < 0 Joriy render vaqtigacha yangilanishlar mavjud emasligini bildiradi (yuqoridagi ilovaga qarang getBaseUpdate()). Bu o'yin boshida kechikish tufayli sodir bo'lishi mumkin. Bunday holda, biz olingan so'nggi yangilanishdan foydalanamiz.
  2. base bizda mavjud bo'lgan so'nggi yangilanishdir. Buning sababi tarmoqdagi kechikish yoki yomon Internet aloqasi bo'lishi mumkin. Bunday holda, biz o'zimizda mavjud bo'lgan so'nggi yangilanishdan ham foydalanamiz.
  3. Bizda joriy render vaqtidan oldin ham, keyin ham yangilanish bor, shuning uchun ham mumkin interpolyatsiya qilish!

Qolganlarning hammasi state.js oddiy (lekin zerikarli) matematik chiziqli interpolyatsiyani amalga oshirishdir. Agar siz uni o'zingiz o'rganmoqchi bo'lsangiz, oching state.js haqida Github.

2-qism. Backend server

Ushbu qismda biz boshqaruvchi Node.js serverini ko'rib chiqamiz .io o'yiniga misol.

1. Serverga kirish nuqtasi

Veb-serverni boshqarish uchun Node.js nomli mashhur veb-ramkadan foydalanamiz ifoda. U bizning server kirish nuqtasi faylimiz tomonidan sozlanadi src/server/server.js:

server.js 1-qism

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

Esingizdami, birinchi qismda biz Webpackni muhokama qildik? Bu erda biz Webpack konfiguratsiyalarimizdan foydalanamiz. Biz ularni ikki usulda ishlatamiz:

  • Foydalanish webpack-dev-o'rta dastur ishlab chiqish paketlarimizni avtomatik ravishda qayta qurish yoki
  • jildni statik ravishda uzatish dist/, ishlab chiqarish qurib bo'lingandan keyin Webpack bizning fayllarimizni yozadi.

Yana bir muhim vazifa server.js serverni sozlashdan iborat socket.ioBu faqat Express serveriga ulanadi:

server.js 2-qism

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

Serverga socket.io ulanishini muvaffaqiyatli o'rnatganimizdan so'ng, biz yangi rozetka uchun voqea ishlov beruvchilarini o'rnatdik. Voqealar ishlov beruvchilari mijozlardan olingan xabarlarni yagona ob'ektga o'tkazish orqali boshqaradi game:

server.js 3-qism

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 o'yinini yaratmoqdamiz, shuning uchun bizga faqat bitta nusxa kerak Game ("O'yin") - barcha o'yinchilar bitta arenada o'ynashadi! Keyingi bo'limda biz ushbu sinf qanday ishlashini ko'rib chiqamiz. Game.

2. O'yin serverlari

sinf Game server tomonidagi eng muhim mantiqni o'z ichiga oladi. Uning ikkita asosiy vazifasi bor: o'yinchi boshqaruvi и o'yin simulyatsiyasi.

Keling, birinchi vazifa, o'yinchi boshqaruvidan boshlaylik.

game.js 1-qism

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 o'yinda maydon bo'yicha futbolchilarni aniqlaymiz id ularning socket.io soketi (agar siz chalkashib ketsangiz, u holda ga qayting server.js). Socket.io o'zi har bir rozetkaga o'ziga xoslikni tayinlaydi idshuning uchun biz bu haqda tashvishlanishga hojat yo'q. Men unga qo'ng'iroq qilaman O'yinchi identifikatori.

Shuni yodda tutgan holda, keling, sinfdagi misol o'zgaruvchilarini o'rganamiz Game:

  • sockets o'yinchi identifikatorini o'yinchi bilan bog'langan rozetkaga bog'laydigan ob'ekt. Bu bizga doimiy ravishda o'yinchi identifikatorlari bo'yicha rozetkalarga kirish imkonini beradi.
  • players o'yinchi identifikatorini kod>Player ob'ektiga bog'laydigan ob'ekt

bullets ob'ektlar massividir Bullet, buning aniq tartibi yo'q.
lastUpdateTime o'yin oxirgi marta yangilangan vaqt belgisidir. U qanday ishlatilishini tez orada ko'rib chiqamiz.
shouldSendUpdate yordamchi o‘zgaruvchidir. Tez orada uning ishlatilishini ham ko'ramiz.
Usullari addPlayer(), removePlayer() и handleInput() tushuntirishga hojat yo'q, ular ishlatiladi server.js. Xotirangizni yangilashingiz kerak bo'lsa, biroz yuqoriroqqa qayting.

Oxirgi qator constructor() boshlanadi yangilash davri o'yinlar (60 yangilanish / s chastota bilan):

game.js 2-qism

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

  // ...
}

usul update() ehtimol server tomoni mantiqining eng muhim qismini o'z ichiga oladi. Mana, u nima qiladi, tartibda:

  1. Qancha vaqtni hisoblaydi dt oxirgisidan beri o'tdi update().
  2. Har bir snaryadni yangilaydi va kerak bo'lganda ularni yo'q qiladi. Ushbu funktsiyaning amalga oshirilishini keyinroq ko'ramiz. Hozircha buni bilishimiz kifoya bullet.update() qaytadi trueagar snaryadni yo'q qilish kerak bo'lsa (U arenadan chiqdi).
  3. Har bir o'yinchini yangilaydi va agar kerak bo'lsa, o'qni chiqaradi. Biz ushbu amaliyotni keyinroq ko'ramiz - player.update() ob'ektni qaytarishi mumkin Bullet.
  4. Snaryadlar va o'yinchilar o'rtasidagi to'qnashuvlarni tekshiradi applyCollisions(), bu o'yinchilarni urgan bir qator snaryadlarni qaytaradi. Qaytgan har bir snaryad uchun biz uni otgan o'yinchining ochkolarini oshiramiz ( player.onDealtDamage()) va keyin snaryadni massivdan olib tashlang bullets.
  5. Barcha o'ldirilgan o'yinchilarni xabardor qiladi va yo'q qiladi.
  6. Barcha o'yinchilarga o'yin yangilanishini yuboradi har soniyada chaqirilgan paytlar update(). Bu bizga yuqorida aytib o'tilgan yordamchi o'zgaruvchini kuzatishga yordam beradi. shouldSendUpdate. Sifatida update() 60 marta / s chaqirildi, biz o'yin yangilanishlarini 30 marta / s yuboramiz. Shunday qilib, soat chastotasi server soati 30 soat/s (biz birinchi qismda soat tezligi haqida gapirgan edik).

Nima uchun faqat o'yin yangilanishlarini yuboring vaqt orqali ? Kanalni saqlash uchun. Bir soniyada 30 ta o'yin yangilanishi juda ko'p!

Nega shunchaki qo'ng'iroq qilmaysiz update() soniyada 30 marta? O'yin simulyatsiyasini yaxshilash uchun. Ko'proq chaqiriladi update(), o'yin simulyatsiyasi qanchalik aniq bo'ladi. Ammo ko'p qiyinchiliklarga berilmang. update(), chunki bu hisoblash qimmat vazifa - soniyada 60 ta etarli.

Sinfning qolgan qismi Game da qoʻllaniladigan yordamchi usullardan iborat update():

game.js 3-qism

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() juda oddiy - u o'yinchilarni ballar bo'yicha saralaydi, eng yaxshi beshlikni oladi va har biri uchun foydalanuvchi nomi va ballni qaytaradi.

createUpdate() yilda ishlatilgan update() o'yinchilarga tarqatiladigan o'yin yangilanishlarini yaratish. Uning asosiy vazifasi usullarni chaqirishdir serializeForUpdate()sinflar uchun amalga oshiriladi Player и Bullet. E'tibor bering, u faqat har bir o'yinchiga ma'lumotlarni uzatadi eng yaqin o'yinchilar va raketalar - o'yinchidan uzoqda joylashgan o'yin ob'ektlari haqida ma'lumot uzatishning hojati yo'q!

3. Serverdagi o'yin ob'ektlari

Bizning o'yinimizda snaryadlar va o'yinchilar aslida juda o'xshash: ular mavhum, yumaloq, harakatlanuvchi o'yin ob'ektlari. O'yinchilar va raketalar o'rtasidagi o'xshashlikdan foydalanish uchun asosiy sinfni amalga oshirishdan boshlaylik 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,
    };
  }
}

Bu erda hech qanday murakkab narsa yo'q. Bu sinf kengaytma uchun yaxshi langar nuqtasi bo'ladi. Keling, sinf qanday ekanligini ko'rib chiqaylik Bullet ispolzet Object:

bullet.js

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

class Bullet extends ObjectClass {
  constructor(parentID, x, y, dir) {
    super(shortid(), x, y, dir, Constants.BULLET_SPEED);
    this.parentID = parentID;
  }

  // Returns true if the bullet should be destroyed
  update(dt) {
    super.update(dt);
    return this.x < 0 || this.x > Constants.MAP_SIZE || this.y < 0 || this.y > Constants.MAP_SIZE;
  }
}

Реализация Bullet juda qisqa! Biz qo'shdik Object faqat quyidagi kengaytmalar:

  • Paketdan foydalanish qisqa tasodifiy avlod uchun id snaryad.
  • Maydon qo'shish parentIDShunday qilib, siz ushbu raketani yaratgan o'yinchini kuzatishingiz mumkin.
  • Qaytish qiymatini qo'shish update()ga teng trueagar snaryad arenadan tashqarida bo'lsa (bu haqda oxirgi bo'limda gaplashganimizni eslaysizmi?).

Keling, davom etaylik 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,
    };
  }
}

O'yinchilar snaryadlarga qaraganda murakkabroq, shuning uchun bu sinfda yana bir nechta maydonlarni saqlash kerak. Uning usuli update() ko'p ishlarni bajaradi, xususan, agar hech kim qolmasa, yangi yaratilgan snaryadni qaytaradi fireCooldown (esingizdami, biz bu haqda oldingi bo'limda gaplashgan edik?). Shuningdek, u usulni kengaytiradi serializeForUpdate(), chunki biz o'yinni yangilashda o'yinchi uchun qo'shimcha maydonlarni kiritishimiz kerak.

Asosiy sinfga ega bo'lish Object - kodni takrorlamaslik uchun muhim qadam. Masalan, sinf yo'q Object har bir o'yin ob'ekti bir xil dasturga ega bo'lishi kerak distanceTo(), va bu ilovalarning barchasini bir nechta fayllarga nusxa ko'chirish - dahshatli tush bo'ladi. Bu, ayniqsa, yirik loyihalar uchun muhim bo'ladi.soni kengayganda Object sinflar o'sib bormoqda.

4. To'qnashuvni aniqlash

Bizga qolgan yagona narsa - snaryadlar o'yinchilarga qachon tushganini bilish! Usuldan ushbu kod qismini eslab qoling update() sinfda 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 usulni amalga oshirishimiz kerak applyCollisions(), bu o'yinchilarni urgan barcha snaryadlarni qaytaradi. Yaxshiyamki, buni qilish unchalik qiyin emas, chunki

  • Barcha to'qnashuvchi ob'ektlar doiralardir, bu to'qnashuvni aniqlashni amalga oshirish uchun eng oddiy shakldir.
  • Bizda allaqachon usul mavjud distanceTo(), biz oldingi bo'limda sinfda amalga oshirgan edik Object.

To'qnashuvni aniqlashni amalga oshirishimiz quyidagicha ko'rinadi:

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 oddiy to'qnashuvni aniqlash haqiqatga asoslanadi agar markazlari orasidagi masofa radiuslari yig'indisidan kichik bo'lsa, ikkita doira to'qnashadi. Ikki doiraning markazlari orasidagi masofa ularning radiuslari yig'indisiga to'liq teng bo'lgan holat:

Multiplayer .io veb-o'yinini yaratish
Bu erda yana bir nechta jihatlarni ko'rib chiqish kerak:

  • Snaryad uni yaratgan o'yinchiga tegmasligi kerak. Bunga solishtirish orqali erishish mumkin bullet.parentID с player.id.
  • Bir vaqtning o'zida bir nechta o'yinchilar to'qnashganda, snaryad faqat bir marta tegishi kerak. Biz bu muammoni operator yordamida hal qilamiz break: snaryad bilan to'qnashgan o'yinchi topilishi bilan biz qidiruvni to'xtatamiz va keyingi raketaga o'tamiz.

Oxiri

Ana xolos! Biz .io veb-o'yinini yaratish uchun bilishingiz kerak bo'lgan hamma narsani ko'rib chiqdik. Keyin nima? O'zingizning .io o'yiningizni yarating!

Barcha namuna kodi ochiq manba va saytga joylashtirilgan Github.

Manba: www.habr.com

a Izoh qo'shish