إنشاء لعبة ويب متعددة اللاعبين .io

إنشاء لعبة ويب متعددة اللاعبين .io
صدر في عام 2015 Agar.io أصبح السلف لنوع جديد ألعاب .ioالذي نمت شعبيته منذ ذلك الحين. لقد شهدت شخصيًا ارتفاعًا في شعبية ألعاب .io: على مدار السنوات الثلاث الماضية ، عرفت ذلك صنع وبيع لعبتين من هذا النوع..

إذا لم تكن قد سمعت بهذه الألعاب من قبل ، فهذه ألعاب ويب مجانية متعددة اللاعبين يسهل لعبها (لا يلزم وجود حساب). عادة ما يواجهون العديد من اللاعبين المتنافسين في نفس الساحة. ألعاب .io الشهيرة الأخرى: Slither.io и Diep.io.

في هذا المنشور ، سوف نستكشف كيف إنشاء لعبة .io من البداية. لهذا ، ستكون معرفة جافا سكريبت فقط كافية: تحتاج إلى فهم أشياء مثل بناء الجملة ES6، الكلمة الرئيسية this и وعود. حتى لو لم تكن معرفتك بجافا سكريبت مثالية ، فلا يزال بإمكانك فهم معظم المنشور.

مثال لعبة .io

للمساعدة في التعلم ، سوف نشير إلى مثال لعبة .io. حاول تشغيله!

إنشاء لعبة ويب متعددة اللاعبين .io
اللعبة بسيطة للغاية: تتحكم في سفينة في ساحة يوجد فيها لاعبون آخرون. تقوم سفينتك تلقائيًا بإطلاق مقذوفات وتحاول إصابة لاعبين آخرين مع تجنب مقذوفاتهم.

1. نظرة عامة موجزة / هيكل المشروع

أنا أوصي تنزيل شفرة المصدر لعبة على سبيل المثال حتى تتمكن من متابعتي.

يستخدم المثال ما يلي:

  • اكسبريس هو إطار عمل ويب Node.js الأكثر شيوعًا والذي يدير خادم الويب الخاص باللعبة.
  • المقبس - مكتبة websocket لتبادل البيانات بين المتصفح والخادم.
  • Webpack - مدير الوحدة. يمكنك أن تقرأ عن سبب استخدام 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 الخاص بنا:

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/. سأسمي هذا الملف الخاص بنا حزمة شبيبة.
  • نحن نستخدم بابل، وعلى وجه الخصوص التكوين @ babel / preset-env لتحويل كود JS الخاص بنا للمتصفحات الأقدم.
  • نحن نستخدم مكونًا إضافيًا لاستخراج جميع CSS المشار إليها بواسطة ملفات JS ودمجها في مكان واحد. سوف اتصل به لدينا حزمة css.

ربما لاحظت أسماء ملفات حزمة غريبة '[name].[contenthash].ext'. انهم يحتوون بدائل اسم الملف حزمة الويب: [name] سيتم استبداله باسم نقطة الإدخال (في حالتنا ، هذا gameم)، و [contenthash] سيتم استبداله بتجزئة لمحتويات الملف. نحن نفعل ذلك ل تحسين المشروع للتجزئة - يمكنك إخبار المتصفحات بتخزين حزم JS الخاصة بنا مؤقتًا إلى أجل غير مسمى ، لأن إذا تغيرت الحزمة ، فإن اسم الملف الخاص بها يتغير أيضًا (التغييرات contenthash). ستكون النتيجة النهائية اسم ملف العرض 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لتحسين أحجام الحزم عند النشر في الإنتاج.

الاعداد المحلي

أوصي بتثبيت المشروع على جهاز محلي حتى تتمكن من اتباع الخطوات المذكورة في هذا المنشور. الإعداد بسيط: أولاً ، يجب تثبيت النظام العقدة и الآلية الوقائية الوطنية. التالي عليك القيام به

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

وأنت على استعداد للذهاب! لبدء خادم التطوير ، ما عليك سوى تشغيل

$ npm run develop

وانتقل إلى متصفح الويب المضيف المحلي: 3000. سيعيد خادم التطوير تلقائيًا بناء حزم JS و CSS مع تغير الكود - فقط قم بتحديث الصفحة لترى كل التغييرات!

3. نقاط دخول العميل

دعنا ننتقل إلى رمز اللعبة نفسه. أولا نحن بحاجة إلى صفحة index.html، عند زيارة الموقع ، سيقوم المتصفح بتحميله أولاً. ستكون صفحتنا بسيطة جدًا:

index.html و

مثال على لعبة .io  يلعب

تم تبسيط مثال الكود هذا قليلاً من أجل الوضوح ، وسأفعل الشيء نفسه مع العديد من أمثلة المنشورات الأخرى. يمكن دائمًا عرض الكود الكامل على جيثب.

لدينا:

  • عنصر لوحة HTML5 (<canvas>) التي سنستخدمها لتقديم اللعبة.
  • <link> لإضافة حزمة CSS الخاصة بنا.
  • <script> لإضافة حزمة جافا سكريبت الخاصة بنا.
  • القائمة الرئيسية مع اسم المستخدم <input> وزر التشغيل (<button>).

بعد تحميل الصفحة الرئيسية ، سيبدأ المتصفح في تنفيذ كود Javascript ، بدءًا من ملف 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. بعد الانتهاء من المرحلة الثالثة يتم عرض القائمة الرئيسية (playMenu).
  5. ضبط المعالج للضغط على زر "PLAY". عندما يتم الضغط على الزر ، يقوم الكود بتهيئة اللعبة ويخبر الخادم بأننا جاهزون للعب.

إن "العنصر الأساسي" لمنطق خادم العميل لدينا يكمن في تلك الملفات التي تم استيرادها بواسطة الملف index.js. الآن سننظر في كل منهم بالترتيب.

4. تبادل بيانات العملاء

في هذه اللعبة ، نستخدم مكتبة معروفة للتواصل مع الخادم المقبس. Socket.io لديه دعم أصلي WebSockets، وهي مناسبة تمامًا للاتصال ثنائي الاتجاه: يمكننا إرسال رسائل إلى الخادم и يمكن للخادم إرسال رسائل إلينا على نفس الاتصال.

سيكون لدينا ملف واحد src/client/networking.jsمن سيهتم به جميع التواصل مع الخادم:

الشبكات. 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. تقديم العميل

حان الوقت لعرض الصورة على الشاشة!

... ولكن قبل أن نتمكن من القيام بذلك ، نحتاج إلى تنزيل جميع الصور (الموارد) اللازمة لذلك. لنكتب مدير موارد:

الأصول. 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 / العميل / Render.js.

6. مدخلات العميل

حان الوقت لصنع لعبة للعب! سيكون نظام التحكم بسيطًا جدًا: لتغيير اتجاه الحركة ، يمكنك استخدام الماوس (على الكمبيوتر) أو لمس الشاشة (على جهاز محمول). لتنفيذ هذا ، سوف نسجل المستمعين الحدث لأحداث Mouse and Touch.
سوف تعتني بكل هذا src/client/input.js:

المدخلات. 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. حالة العميل

هذا القسم هو الأصعب في الجزء الأول من التدوينة. لا تثبط عزيمتك إذا لم تفهمها في المرة الأولى التي تقرأها! يمكنك حتى تخطيه والعودة إليه لاحقًا.

آخر جزء من اللغز المطلوب لإكمال رمز العميل / الخادم هو حالة. هل تتذكر مقتطف الشفرة من قسم عرض العميل؟

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() يمكن فقط إرجاع بيانات آخر تحديث للعبة تم استلامه مباشرةً.

ساذج-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. خادم الخلفية

في هذا الجزء ، سنلقي نظرة على الواجهة الخلفية Node.js التي تتحكم في ملف مثال لعبة .io.

1. نقطة دخول الخادم

لإدارة خادم الويب ، سنستخدم إطار عمل ويب شائعًا لـ Node.js يسمى اكسبريس. سيتم تكوينه بواسطة ملف نقطة دخول الخادم الخاص بنا src/server/server.js:

server.js الجزء الأول

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 هو إعداد الخادم المقبسالذي يتصل فقط بالخادم السريع:

server.js الجزء الأول

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 الجزء الأول

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 socket.io (إذا شعرت بالارتباك ، فارجع إلى server.js). يقوم Socket.io نفسه بتعيين كل مقبس ملف idلذلك لا داعي للقلق بشأن ذلك. ساتصل به معرف اللاعب.

مع أخذ ذلك في الاعتبار ، دعنا نستكشف متغيرات الحالة في الفصل Game:

  • sockets هو كائن يربط معرف اللاعب بالمقبس المرتبط باللاعب. يسمح لنا بالوصول إلى المقابس من خلال معرفات اللاعبين الخاصة بهم في وقت ثابت.
  • players هو كائن يربط معرف المشغل بالكود> كائن Player

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.

إليك ما يبدو عليه تنفيذنا لاكتشاف الاصطدام:

الاصطدامات. 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

إضافة تعليق