ایجاد یک بازی وب چند نفره .io

ایجاد یک بازی وب چند نفره .io
در سال 2015 منتشر شد Agar.io مولد یک ژانر جدید شد بازی ها .ioکه از آن زمان بر محبوبیت آن افزوده شده است. من شخصاً افزایش محبوبیت بازی‌های io را تجربه کرده‌ام: طی سه سال گذشته، تجربه کرده‌ام ساخت و فروش دو بازی از این سبک..

اگر قبلاً هرگز نام این بازی‌ها را نشنیده‌اید، اینها بازی‌های وب چندنفره رایگان هستند که بازی کردن آنها آسان است (بدون نیاز به حساب کاربری). آنها معمولاً در یک میدان با بازیکنان حریف زیادی روبرو می شوند. سایر بازی های معروف .io: Slither.io и Diep.io.

در این پست به بررسی نحوه انجام آن می پردازیم یک بازی .io را از ابتدا ایجاد کنید. برای این، فقط دانش جاوا اسکریپت کافی خواهد بود: شما باید چیزهایی مانند نحو را درک کنید ES6، کلمه کلیدی this и وعده. حتی اگر دانش شما از جاوا اسکریپت کامل نباشد، باز هم می توانید بیشتر پست را درک کنید.

نمونه بازی .io

برای کمک به یادگیری به نمونه بازی .io. سعی کنید آن را بازی کنید!

ایجاد یک بازی وب چند نفره .io
بازی بسیار ساده است: شما یک کشتی را در عرصه ای کنترل می کنید که بازیکنان دیگر در آن حضور دارند. کشتی شما به طور خودکار پرتابه ها را شلیک می کند و شما سعی می کنید به بازیکنان دیگر ضربه بزنید در حالی که از پرتابه های آنها اجتناب می کنید.

1. نمای کلی / ساختار پروژه

توصیه دانلود کد منبع بازی نمونه تا بتوانید من را دنبال کنید.

مثال از موارد زیر استفاده می کند:

  • صریح محبوب ترین چارچوب وب Node.js است که وب سرور بازی را مدیریت می کند.
  • socket.io - یک کتابخانه وب سوکت برای تبادل داده بین مرورگر و سرور.
  • صفحه وب - مدیر ماژول می توانید در مورد چرایی استفاده از Webpack مطالعه کنید. اینجا.

در اینجا ساختار دایرکتوری پروژه به نظر می رسد:

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

عمومی/

همه چیز در یک پوشه public/ به صورت ایستا توسط سرور ارسال خواهد شد. که در public/assets/ شامل تصاویر مورد استفاده پروژه ما است.

src /

تمام کد منبع در پوشه است src/. نامگذاری client/ и server/ برای خود صحبت کنند و shared/ حاوی یک فایل ثابت است که هم توسط کلاینت و هم توسط سرور وارد می شود.

2. مجموعه ها/تنظیمات پروژه

همانطور که در بالا ذکر شد، ما از مدیر ماژول برای ساخت پروژه استفاده می کنیم. صفحه وب. بیایید نگاهی به پیکربندی Webpack خود بیندازیم:

webpack.common.js:

const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = {
  entry: {
    game: './src/client/index.js',
  },
  output: {
    filename: '[name].[contenthash].js',
    path: path.resolve(__dirname, 'dist'),
  },
  module: {
    rules: [
      {
        test: /.js$/,
        exclude: /node_modules/,
        use: {
          loader: "babel-loader",
          options: {
            presets: ['@babel/preset-env'],
          },
        },
      },
      {
        test: /.css$/,
        use: [
          {
            loader: MiniCssExtractPlugin.loader,
          },
          'css-loader',
        ],
      },
    ],
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: '[name].[contenthash].css',
    }),
    new HtmlWebpackPlugin({
      filename: 'index.html',
      template: 'src/client/html/index.html',
    }),
  ],
};

مهمترین خطوط در اینجا عبارتند از:

  • src/client/index.js نقطه ورود کلاینت جاوا اسکریپت (JS) است. Webpack از اینجا شروع می شود و به صورت بازگشتی برای سایر فایل های وارد شده جستجو می کند.
  • JS خروجی ساخت Webpack ما در دایرکتوری قرار خواهد گرفت dist/. من این فایل را ما می نامم بسته js.
  • ما استفاده می کنیم هرج و مرج، و به ویژه پیکربندی @babel/preset-env برای انتقال کد JS ما برای مرورگرهای قدیمی تر.
  • ما از یک پلاگین برای استخراج تمام CSS های ارجاع شده توسط فایل های JS و ترکیب آنها در یک مکان استفاده می کنیم. من او را خودمان صدا خواهم کرد بسته css.

ممکن است متوجه نام فایل های بسته های عجیب و غریب شده باشید '[name].[contenthash].ext'. آنها حاوی جایگزینی نام فایل بسته وب: [name] با نام نقطه ورودی جایگزین می شود (در مورد ما، این game)، آ [contenthash] با یک هش از محتویات فایل جایگزین می شود. ما آن را انجام می دهیم بهینه سازی پروژه برای هش - می توانید به مرورگرها بگویید که بسته های JS ما را به طور نامحدود کش کنند، زیرا اگر بسته ای تغییر کند، نام فایل آن نیز تغییر می کند (تغییر می کند contenthash). نتیجه نهایی نام فایل view خواهد بود game.dbeee76e91a97d0c7207.js.

پرونده webpack.common.js فایل پیکربندی پایه است که ما به پیکربندی های پروژه توسعه و تکمیل شده وارد می کنیم. در اینجا یک نمونه پیکربندی توسعه آورده شده است:

webpack.dev.js

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

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

برای بهره وری، ما در فرآیند توسعه استفاده می کنیم webpack.dev.js، و به webpack.prod.jsبرای بهینه سازی اندازه بسته ها هنگام استقرار در تولید.

تنظیمات محلی

توصیه می کنم پروژه را روی یک ماشین محلی نصب کنید تا بتوانید مراحل ذکر شده در این پست را دنبال کنید. راه اندازی ساده است: ابتدا سیستم باید نصب شده باشد گره и NPM. بعد شما باید انجام دهید

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

و شما آماده رفتن هستید! برای راه اندازی سرور توسعه، فقط اجرا کنید

$ npm run develop

و به مرورگر وب بروید localhost: 3000. سرور توسعه به طور خودکار بسته های JS و CSS را با تغییر کد بازسازی می کند - فقط صفحه را بازخوانی کنید تا همه تغییرات را مشاهده کنید!

3. نقاط ورود مشتری

بیایید به خود کد بازی برسیم. ابتدا به یک صفحه نیاز داریم index.html، هنگام بازدید از سایت، ابتدا مرورگر آن را بارگذاری می کند. صفحه ما بسیار ساده خواهد بود:

index.html به

یک نمونه بازی .io  بازی

این مثال کد برای وضوح کمی ساده شده است، و من همین کار را با بسیاری از نمونه های پست دیگر انجام خواهم داد. کد کامل همیشه در قابل مشاهده است گیتهاب.

ما داریم:

  • عنصر بوم HTML5 (<canvas>) که برای رندر بازی از آن استفاده خواهیم کرد.
  • <link> برای اضافه کردن بسته CSS ما.
  • <script> برای اضافه کردن بسته جاوا اسکریپت ما.
  • منوی اصلی با نام کاربری <input> و دکمه PLAY (<button>).

پس از بارگیری صفحه اصلی، مرورگر شروع به اجرای کد جاوا اسکریپت می کند و از فایل JS نقطه ورودی شروع می شود: src/client/index.js.

index.js

import { connect, play } from './networking';
import { startRendering, stopRendering } from './render';
import { startCapturingInput, stopCapturingInput } from './input';
import { downloadAssets } from './assets';
import { initState } from './state';
import { setLeaderboardHidden } from './leaderboard';

import './css/main.css';

const playMenu = document.getElementById('play-menu');
const playButton = document.getElementById('play-button');
const usernameInput = document.getElementById('username-input');

Promise.all([
  connect(),
  downloadAssets(),
]).then(() => {
  playMenu.classList.remove('hidden');
  usernameInput.focus();
  playButton.onclick = () => {
    // Play!
    play(usernameInput.value);
    playMenu.classList.add('hidden');
    initState();
    startCapturingInput();
    startRendering();
    setLeaderboardHidden(false);
  };
});

این ممکن است پیچیده به نظر برسد، اما چیز زیادی در اینجا وجود ندارد:

  1. وارد کردن چندین فایل JS دیگر.
  2. وارد کردن CSS (بنابراین Webpack بداند که آنها را در بسته CSS ما قرار دهد).
  3. راه اندازی connect() برای ایجاد ارتباط با سرور و اجرا downloadAssets() برای دانلود تصاویر مورد نیاز برای رندر بازی.
  4. پس از اتمام مرحله 3 منوی اصلی نمایش داده می شود (playMenu).
  5. تنظیم کننده برای فشار دادن دکمه "PLAY". هنگامی که دکمه فشار داده می شود، کد بازی را مقداردهی اولیه می کند و به سرور می گوید که ما آماده بازی هستیم.

"گوشت" اصلی منطق مشتری-سرور ما در آن فایل هایی است که توسط فایل وارد شده اند index.js. اکنون همه آنها را به ترتیب در نظر می گیریم.

4. تبادل اطلاعات مشتری

در این بازی از یک کتابخانه معروف برای ارتباط با سرور استفاده می کنیم socket.io. Socket.io پشتیبانی بومی دارد صفحات وب، که برای ارتباط دو طرفه مناسب هستند: می توانیم پیام هایی را به سرور ارسال کنیم и سرور می تواند در همان اتصال برای ما پیام ارسال کند.

ما یک فایل خواهیم داشت src/client/networking.jsکه مراقبت خواهد کرد توسط همه ارتباط با سرور:

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

این کد نیز برای وضوح کمی کوتاه شده است.

سه عمل اصلی در این فایل وجود دارد:

  • ما در حال تلاش برای اتصال به سرور هستیم. connectedPromise فقط زمانی مجاز است که ما ارتباط برقرار کرده باشیم.
  • اگر اتصال موفقیت آمیز باشد، توابع پاسخ به تماس را ثبت می کنیم (processGameUpdate() и onGameOver()) برای پیام هایی که می توانیم از سرور دریافت کنیم.
  • ما صادر می کنیم play() и updateDirection()تا سایر فایل ها بتوانند از آنها استفاده کنند.

5. ارائه مشتری

زمان نمایش تصویر روی صفحه است!

... اما قبل از انجام این کار، باید تمام تصاویر (منابع) مورد نیاز برای این کار را دانلود کنیم. بیایید یک مدیر منابع بنویسیم:

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

پیاده سازی مدیریت منابع چندان سخت نیست! ایده اصلی این است که یک شی را ذخیره کنید assets، که کلید نام فایل را به مقدار شی متصل می کند Image. هنگامی که منبع بارگذاری می شود، آن را در یک شی ذخیره می کنیم assets برای دسترسی سریع در آینده چه زمانی به هر منبع مجزا اجازه دانلود داده می شود (یعنی، همه منابع)، ما اجازه می دهیم downloadPromise.

پس از دانلود منابع، می توانید شروع به رندر کنید. همانطور که قبلاً گفته شد، برای ترسیم در یک صفحه وب، از آن استفاده می کنیم بوم HTML5 (<canvas>). بازی ما بسیار ساده است، بنابراین فقط باید موارد زیر را ترسیم کنیم:

  1. زمینه
  2. کشتی بازیکن
  3. سایر بازیکنان در بازی
  4. پوسته

در اینجا قطعات مهم وجود دارد src/client/render.js، که دقیقاً چهار مورد ذکر شده در بالا را نشان می دهد:

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

این کد نیز برای وضوح کوتاه شده است.

render() عملکرد اصلی این فایل است. startRendering() и stopRendering() فعال شدن حلقه رندر را با سرعت 60 فریم در ثانیه کنترل کنید.

اجرای دقیق توابع کمکی رندر فردی (به عنوان مثال renderBullet()) چندان مهم نیستند، اما در اینجا یک مثال ساده وجود دارد:

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

توجه داشته باشید که ما از روش استفاده می کنیم getAsset()، که قبلا در asset.js!

اگر علاقه مند به یادگیری سایر کمک های رندر هستید، بقیه را بخوانید. src/client/render.js.

6. ورودی مشتری

زمان ساخت یک بازی است قابل پخش! طرح کنترل بسیار ساده خواهد بود: برای تغییر جهت حرکت، می توانید از ماوس (در رایانه) استفاده کنید یا صفحه نمایش را لمس کنید (در یک دستگاه تلفن همراه). برای پیاده سازی این، ما ثبت نام می کنیم شنوندگان رویداد برای رویدادهای ماوس و لمسی.
به همه اینها رسیدگی خواهد کرد 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() شنوندگان رویداد هستند که تماس می گیرند updateDirection() (از networking.js) هنگامی که یک رویداد ورودی رخ می دهد (به عنوان مثال، هنگامی که ماوس حرکت می کند). updateDirection() پیام رسانی را با سرور انجام می دهد، که رویداد ورودی را مدیریت می کند و وضعیت بازی را بر این اساس به روز می کند.

7. وضعیت مشتری

این بخش در قسمت اول پست سخت ترین بخش است. اگر اولین بار که آن را می خوانید، آن را متوجه نشدید، ناامید نشوید! حتی می توانید آن را نادیده بگیرید و بعداً به آن بازگردید.

آخرین قطعه از پازل مورد نیاز برای تکمیل کد کلاینت/سرور است بود. قطعه کد از بخش Client Rendering را به خاطر دارید؟

render.js

import { getCurrentState } from './state';

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

  // Do the rendering
  // ...
}

getCurrentState() باید بتواند وضعیت فعلی بازی را در کلاینت به ما بدهد در هر نقطه از زمان بر اساس به روز رسانی های دریافت شده از سرور. در اینجا نمونه ای از به روز رسانی بازی است که سرور می تواند ارسال کند:

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

هر آپدیت بازی شامل پنج فیلد یکسان است:

  • t: مهر زمانی سرور که نشان دهنده زمان ایجاد این به روز رسانی است.
  • me: اطلاعات مربوط به پخش کننده ای که این به روز رسانی را دریافت می کند.
  • دیگران: مجموعه ای از اطلاعات در مورد سایر بازیکنان شرکت کننده در همان بازی.
  • گلوله: مجموعه ای از اطلاعات در مورد پرتابه های موجود در بازی.
  • رهبران: داده های جدول امتیازات فعلی. در این پست، آنها را در نظر نخواهیم گرفت.

7.1 حالت مشتری ساده لوح

اجرای ساده لوحانه getCurrentState() فقط می‌تواند مستقیماً داده‌های آخرین به‌روزرسانی دریافت‌شده بازی را برگرداند.

naive-state.js

let lastGameUpdate = null;

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

export function getCurrentState() {
  return lastGameUpdate;
}

خوب و واضح! اما اگر به همین سادگی بود. یکی از دلایلی که این پیاده سازی مشکل ساز است: نرخ فریم رندر را به نرخ ساعت سرور محدود می کند.

نرخ فریم: تعداد فریم ها (یعنی تماس ها render()) در ثانیه یا FPS. بازی‌ها معمولاً تلاش می‌کنند تا حداقل 60 فریم در ثانیه به دست آورند.

تیک نرخ: فرکانسی که سرور در آن به روز رسانی های بازی را برای مشتریان ارسال می کند. اغلب کمتر از نرخ فریم است. در بازی ما سرور با فرکانس 30 سیکل در ثانیه اجرا می شود.

اگر فقط آخرین آپدیت بازی را رندر کنیم، اساساً FPS هرگز از 30 بالاتر نمی رود، زیرا ما هرگز بیش از 30 به روز رسانی در ثانیه از سرور دریافت نمی کنیم. حتی اگه زنگ بزنیم render() 60 بار در ثانیه، سپس نیمی از این تماس‌ها همان چیزی را دوباره ترسیم می‌کنند و اساساً هیچ کاری انجام نمی‌دهند. مشکل دیگر اجرای ساده لوحانه این است که آن را انجام می دهد مستعد تاخیر. با سرعت اینترنت ایده آل، مشتری دقیقاً هر 33 میلی ثانیه (30 در ثانیه) یک به روز رسانی بازی دریافت می کند:

ایجاد یک بازی وب چند نفره .io
متاسفانه هیچ چیز کامل نیست. تصویر واقعی تر این خواهد بود:
ایجاد یک بازی وب چند نفره .io
پیاده سازی ساده لوحانه عملاً بدترین حالت در مورد تأخیر است. اگر آپدیت بازی با 50 میلی ثانیه تاخیر دریافت شود، پس غرفه های مشتری 50 میلی‌ثانیه اضافی چون هنوز وضعیت بازی را از به‌روزرسانی قبلی ارائه می‌کند. می توانید تصور کنید که این برای بازیکن چقدر ناراحت کننده است: ترمزهای خودسرانه باعث می شود که بازی تند و ناپایدار شود.

7.2 بهبود وضعیت مشتری

ما برخی بهبودها را در پیاده سازی ساده لوحانه انجام خواهیم داد. ابتدا استفاده می کنیم تاخیر رندر به مدت 100 میلی ثانیه این بدان معنی است که وضعیت "فعالی" مشتری همیشه 100 میلی ثانیه از وضعیت بازی روی سرور عقب می ماند. برای مثال، اگر زمان روی سرور باشد 150، سپس کلاینت وضعیتی را که سرور در آن زمان در آن قرار داشت ارائه می کند 50:

ایجاد یک بازی وب چند نفره .io
این یک بافر 100 میلی‌ثانیه برای زنده ماندن در زمان‌های به‌روزرسانی غیرقابل پیش‌بینی بازی به ما می‌دهد:

ایجاد یک بازی وب چند نفره .io
بازده این امر دائمی خواهد بود تاخیر ورودی به مدت 100 میلی ثانیه این یک فداکاری جزئی برای یک گیم پلی روان است - اکثر بازیکنان (به خصوص بازیکنان معمولی) حتی متوجه این تاخیر نمی شوند. تطبیق با تأخیر ثابت 100 میلی‌ثانیه برای افراد بسیار آسان‌تر از بازی با تأخیر غیرقابل پیش‌بینی است.

همچنین می توانیم از تکنیک دیگری به نام استفاده کنیم پیش بینی سمت مشتری، که در کاهش تأخیر درک شده به خوبی عمل می کند، اما در این پست به آن پرداخته نخواهد شد.

یکی دیگر از پیشرفت هایی که ما استفاده می کنیم این است درون یابی خطی. به دلیل تاخیر رندر، ما معمولا حداقل یک به روز رسانی جلوتر از زمان فعلی در مشتری هستیم. وقتی تماس گرفته شود getCurrentState()، می توانیم اجرا کنیم درون یابی خطی بین به‌روزرسانی‌های بازی درست قبل و بعد از زمان فعلی در مشتری:

ایجاد یک بازی وب چند نفره .io
این مشکل نرخ فریم را حل می کند: اکنون می توانیم فریم های منحصر به فرد را با هر نرخ فریمی که می خواهیم ارائه دهیم!

7.3 پیاده سازی وضعیت مشتری پیشرفته

مثال پیاده سازی در src/client/state.js از هر دو تاخیر رندر و درون یابی خطی استفاده می کند، اما نه برای مدت طولانی. بیایید کد را به دو قسمت تقسیم کنیم. در اینجا اولین مورد است:

state.js قسمت 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;
}

اولین قدم این است که بفهمیم چه چیزی currentServerTime(). همانطور که قبلا دیدیم، هر به روز رسانی بازی دارای یک مهر زمان سرور است. ما می خواهیم از تأخیر رندر برای رندر کردن تصویر 100 میلی ثانیه در پشت سرور استفاده کنیم، اما ما هرگز زمان فعلی روی سرور را نمی دانیم، زیرا ما نمی توانیم بدانیم چه مدت طول کشید تا هر یک از به روز رسانی ها به دست ما برسد. اینترنت غیرقابل پیش بینی است و سرعت آن می تواند بسیار متفاوت باشد!

برای دور زدن این مشکل، می‌توانیم از یک تقریب معقول استفاده کنیم: ما وانمود کنید که اولین به روز رسانی فورا رسیده است. اگر این درست بود، ما زمان سرور را در این لحظه خاص می دانستیم! ما مهر زمانی سرور را در آن ذخیره می کنیم firstServerTimestamp و ما را حفظ کن محلی (مشتری) مهر زمانی در همان لحظه در gameStart.

صبر کن. آیا نباید زمان سرور = زمان مشتری باشد؟ چرا ما بین «مهر زمانی سرور» و «مهر زمانی مشتری» تفاوت قائل می شویم؟ این یک سوال عالی است! معلوم است که آنها یک چیز نیستند. Date.now() مهرهای زمانی متفاوتی را در کلاینت و سرور برمی گرداند و به عوامل محلی این ماشین ها بستگی دارد. هرگز تصور نکنید که مهر زمانی در همه ماشین ها یکسان خواهد بود.

حالا می فهمیم که چه کاری انجام می دهد currentServerTime(): برمی گردد مهر زمانی سرور زمان رندر فعلی. به عبارت دیگر، این زمان فعلی سرور است (firstServerTimestamp <+ (Date.now() - gameStart)) منهای تاخیر رندر (RENDER_DELAY).

حالا بیایید نگاهی به نحوه مدیریت به‌روزرسانی‌های بازی بیندازیم. هنگامی که از سرور به روز رسانی دریافت می شود، فراخوانی می شود processGameUpdate()و آپدیت جدید را در یک آرایه ذخیره می کنیم gameUpdates. سپس برای بررسی میزان مصرف حافظه، تمام به‌روزرسانی‌های قدیمی را حذف می‌کنیم به روز رسانی پایهچون ما دیگر به آنها نیاز نداریم

"به روز رسانی اساسی" چیست؟ این اولین به روز رسانی که با حرکت به عقب از زمان فعلی سرور پیدا می کنیم. این نمودار را به خاطر دارید؟

ایجاد یک بازی وب چند نفره .io
به روز رسانی بازی به طور مستقیم در سمت چپ "Client Render Time" به روز رسانی پایه است.

به روز رسانی پایه برای چه مواردی استفاده می شود؟ چرا می‌توانیم به‌روزرسانی‌ها را به خط پایه رها کنیم؟ برای فهمیدن این موضوع، بیایید در آخر اجرا را در نظر بگیرید getCurrentState():

state.js قسمت 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),
    };
  }
}

ما به سه مورد رسیدگی می کنیم:

  1. base < 0 به این معنی است که تا زمان رندر فعلی هیچ به روز رسانی وجود ندارد (به اجرای بالا مراجعه کنید getBaseUpdate()). این می تواند درست در شروع بازی به دلیل تاخیر رندر اتفاق بیفتد. در این صورت از آخرین آپدیت دریافتی استفاده می کنیم.
  2. base آخرین به روز رسانی ما است. این ممکن است به دلیل تاخیر شبکه یا اتصال ضعیف اینترنت باشد. در این مورد، ما نیز از آخرین به روز رسانی که داریم استفاده می کنیم.
  3. ما قبل و بعد از زمان رندر فعلی به‌روزرسانی داریم، بنابراین می‌توانیم درون یابی!

تمام آنچه در آن باقی مانده است state.js اجرای درون یابی خطی است که ریاضی ساده (اما خسته کننده) است. اگر می‌خواهید خودتان آن را کشف کنید، باز کنید state.js بر گیتهاب.

قسمت 2. سرور Backend

در این قسمت نگاهی به باطن Node.js خواهیم داشت که ما را کنترل می کند نمونه بازی .io.

1. نقطه ورود به سرور

برای مدیریت وب سرور، از یک چارچوب وب محبوب برای Node.js به نام استفاده خواهیم کرد صریح. توسط فایل نقطه ورودی سرور ما پیکربندی خواهد شد src/server/server.js:

server.js قسمت 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}`);

به یاد دارید که در قسمت اول به Webpack پرداختیم؟ اینجاست که ما از تنظیمات Webpack خود استفاده خواهیم کرد. ما از آنها به دو صورت استفاده خواهیم کرد:

  • استفاده کنید webpack-dev-middleware برای بازسازی خودکار بسته های توسعه ما، یا
  • انتقال استاتیک پوشه dist/، که Webpack فایل های ما را پس از ساخت تولید در آن می نویسد.

یک کار مهم دیگر server.js راه اندازی سرور است socket.ioکه فقط به سرور Express متصل می شود:

server.js قسمت 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);
});

پس از برقراری موفقیت آمیز اتصال socket.io به سرور، کنترل کننده رویداد را برای سوکت جدید راه اندازی کردیم. کنترل‌کننده‌های رویداد، پیام‌های دریافتی از مشتریان را با تفویض اختیار به یک شیی تک‌تنی مدیریت می‌کنند game:

server.js قسمت 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);
}

ما در حال ایجاد یک بازی .io هستیم، بنابراین فقط به یک نسخه نیاز داریم Game ("بازی") - همه بازیکنان در یک عرصه بازی می کنند! در بخش بعدی نحوه عملکرد این کلاس را خواهیم دید. Game.

2. سرورهای بازی

کلاس Game حاوی مهمترین منطق در سمت سرور است. دو وظیفه اصلی دارد: مدیریت بازیکن и شبیه سازی بازی.

بیایید با اولین کار، مدیریت بازیکن شروع کنیم.

game.js قسمت 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);
    }
  }

  // ...
}

در این بازی بازیکنان را از روی زمین شناسایی می کنیم id سوکت socket.io آنها (اگر گیج شدید، به آن بازگردید server.js). خود Socket.io به هر سوکت یک سوکت منحصر به فرد اختصاص می دهد idبنابراین نیازی به نگرانی در مورد آن نداریم. من با او تماس خواهم گرفت شناسه بازیکن.

با در نظر گرفتن این موضوع، اجازه دهید متغیرهای نمونه را در یک کلاس بررسی کنیم Game:

  • sockets یک شی است که شناسه پخش کننده را به سوکت مرتبط با پخش کننده متصل می کند. این به ما امکان می دهد تا در یک زمان ثابت به سوکت ها با شناسه پخش کننده آنها دسترسی داشته باشیم.
  • players یک شی است که شناسه بازیکن را به کد> شیء پخش کننده متصل می کند

bullets آرایه ای از اشیاء است Bullet، که ترتیب مشخصی ندارد.
lastUpdateTime مهر زمانی آخرین باری است که بازی به روز شده است. به زودی نحوه استفاده از آن را خواهیم دید.
shouldSendUpdate یک متغیر کمکی است. همچنین به زودی شاهد استفاده از آن خواهیم بود.
روش ها addPlayer(), removePlayer() и handleInput() نیازی به توضیح نیست، از آنها استفاده می شود server.js. اگر می خواهید حافظه خود را تازه کنید، کمی به عقب برگردید.

خط آخر constructor() شروع می شود چرخه به روز رسانی بازی ها (با فرکانس 60 به روز رسانی در ثانیه):

game.js قسمت 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;
    }
  }

  // ...
}

روش update() شاید مهمترین بخش منطق سمت سرور را شامل شود. به ترتیب این کار به این صورت است:

  1. مدت زمان را محاسبه می کند dt از آخرین گذشته گذشت update().
  2. هر پرتابه را تازه می کند و در صورت لزوم آنها را از بین می برد. در ادامه شاهد اجرای این قابلیت خواهیم بود. در حال حاضر، دانستن این موضوع برای ما کافی است bullet.update() برمی گرداند trueاگر پرتابه باید نابود شود (از میدان خارج شد).
  3. هر بازیکن را به روز می کند و در صورت لزوم یک پرتابه ایجاد می کند. ما همچنین این پیاده سازی را بعداً خواهیم دید - player.update() می تواند یک شی را برگرداند Bullet.
  4. بررسی برخورد بین پرتابه ها و بازیکنان با applyCollisions()، که آرایه ای از پرتابه هایی را که به بازیکنان اصابت می کند برمی گرداند. به ازای هر گلوله برگشتی، امتیاز بازیکنی که آن را شلیک کرده است (با استفاده از player.onDealtDamage()) و سپس پرتابه را از آرایه بردارید bullets.
  5. همه بازیکنان کشته شده را با خبر و نابود می کند.
  6. آپدیت بازی را برای همه بازیکنان ارسال می کند در هر ثانیه زمان تماس update(). این به ما کمک می کند تا متغیر کمکی ذکر شده در بالا را پیگیری کنیم. shouldSendUpdate... زیرا update() با تماس 60 بار در ثانیه، ما به روز رسانی بازی را 30 بار در ثانیه ارسال می کنیم. بدین ترتیب، فرکانس ساعت ساعت سرور 30 ​​ساعت در ثانیه است (در قسمت اول در مورد نرخ ساعت صحبت کردیم).

چرا فقط به روز رسانی بازی ارسال می شود در طول زمان ? برای ذخیره کانال 30 آپدیت بازی در هر ثانیه زیاد است!

چرا فقط تماس نگیرید update() 30 بار در ثانیه؟ برای بهبود شبیه سازی بازی بیشتر نامیده می شود update()، شبیه سازی بازی دقیق تر خواهد بود. اما با تعداد چالش‌ها زیاد غافل نشوید. update()، زیرا این یک کار محاسباتی گران است - 60 در ثانیه کافی است.

بقیه کلاس Game متشکل از روش های کمکی مورد استفاده در update():

game.js قسمت 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() بسیار ساده - بازیکنان را بر اساس امتیاز مرتب می کند، پنج نفر برتر را می گیرد و نام کاربری و امتیاز هر کدام را برمی گرداند.

createUpdate() مورد استفاده در update() برای ایجاد به روز رسانی های بازی که بین بازیکنان توزیع می شود. وظیفه اصلی آن فراخوانی متدها است serializeForUpdate()برای کلاس ها اجرا می شود Player и Bullet. توجه داشته باشید که فقط اطلاعات مربوط به هر بازیکن را ارسال می کند نزدیکترین بازیکنان و پرتابه ها - نیازی به انتقال اطلاعات در مورد اشیاء بازی که از بازیکن دور هستند وجود ندارد!

3. اشیاء بازی روی سرور

در بازی ما، پرتابه ها و بازیکنان در واقع بسیار شبیه به هم هستند: آنها اشیاء بازی انتزاعی، گرد و متحرک هستند. برای استفاده از این شباهت بین بازیکنان و پرتابه ها، اجازه دهید با پیاده سازی کلاس پایه شروع کنیم 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,
    };
  }
}

هیچ چیز پیچیده ای در اینجا وجود ندارد. این کلاس نقطه اتصال خوبی برای پسوند خواهد بود. بیایید ببینیم کلاس چگونه است Bullet استفاده می کند 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 خیلی کوتاه! ما اضافه کرده ایم Object فقط پسوندهای زیر:

  • با استفاده از پکیج کوتاه قد برای تولید تصادفی id پرتابه
  • افزودن یک فیلد parentIDتا بتوانید بازیکنی را که این پرتابه را ساخته است ردیابی کنید.
  • افزودن مقدار بازگشتی به update()، که برابر است با trueاگر پرتابه خارج از میدان باشد (یادتان هست که در قسمت گذشته در این مورد صحبت کردیم؟).

بیایید به ادامه مطلب برویم 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,
    };
  }
}

بازیکنان پیچیده تر از پرتابه ها هستند، بنابراین باید چند فیلد دیگر در این کلاس ذخیره شود. روش او update() کارهای زیادی انجام می دهد، به ویژه، اگر پرتابه ای باقی نمانده باشد، پرتابه تازه ایجاد شده را برمی گرداند fireCooldown (یادتان هست که در قسمت قبل در این مورد صحبت کردیم؟). همچنین این روش را گسترش می دهد serializeForUpdate()، زیرا باید فیلدهای اضافی را برای بازیکن در آپدیت بازی قرار دهیم.

داشتن کلاس پایه Object - یک گام مهم برای جلوگیری از تکرار کد. مثلا بدون کلاس Object هر شی بازی باید پیاده سازی یکسانی داشته باشد distanceTo()و کپی پیست کردن همه این پیاده سازی ها در چندین فایل یک کابوس خواهد بود. این امر به ویژه برای پروژه های بزرگ مهم می شود.زمانی که تعداد در حال گسترش است Object کلاس ها در حال رشد هستند

4. تشخیص برخورد

تنها چیزی که برای ما باقی می ماند این است که تشخیص دهیم پرتابه ها چه زمانی به بازیکنان اصابت می کند! این کد از متد را به خاطر بسپارید update() در کلاس 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),
    );

    // ...
  }
}

ما باید روش را پیاده کنیم applyCollisions()، که تمام پرتابه هایی را که به بازیکنان اصابت می کند برمی گرداند. خوشبختانه انجام این کار چندان سخت نیست زیرا

  • همه اشیاء برخورد شده دایره هستند، که ساده ترین شکل برای پیاده سازی تشخیص برخورد است.
  • ما قبلاً یک روش داریم distanceTo()، که در قسمت قبل در کلاس پیاده سازی کردیم Object.

در اینجا پیاده سازی تشخیص برخورد ما به نظر می رسد:

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

این تشخیص برخورد ساده مبتنی بر این واقعیت است که دو دایره در صورتی با هم برخورد می کنند که فاصله مراکز آنها کمتر از مجموع شعاع آنها باشد. در اینجا موردی است که فاصله بین مرکز دو دایره دقیقاً برابر با مجموع شعاع آنها است:

ایجاد یک بازی وب چند نفره .io
در اینجا چند جنبه دیگر وجود دارد که باید مورد توجه قرار گیرد:

  • پرتابه نباید به بازیکنی که آن را ایجاد کرده است برخورد کند. این را می توان با مقایسه به دست آورد bullet.parentID с player.id.
  • پرتابه باید فقط یک بار در مورد محدود کننده برخورد چند بازیکن همزمان با هم برخورد کند. با استفاده از اپراتور این مشکل را حل خواهیم کرد break: به محض پیدا شدن بازیکنی که با پرتابه برخورد می کند، جستجو را متوقف می کنیم و به سراغ پرتابه بعدی می رویم.

پایان

همین! ما همه چیزهایی را که برای ایجاد یک بازی وب .io باید بدانید را پوشش داده ایم. بعدش چی؟ بازی .io خود را بسازید!

تمام کدهای نمونه متن باز هستند و در آن پست شده اند گیتهاب.

منبع: www.habr.com

اضافه کردن نظر