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

صورة

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

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

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

مثال لعبة


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


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

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


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

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

  • Express هو أكثر أطر الويب شعبية لـ Node.js التي تدير خادم الويب الخاص باللعبة.
  • socket.io - مكتبة websocket لتبادل البيانات بين المتصفح والخادم.
  • Webpack هو مدير وحدة. يمكنك أن تقرأ عن سبب استخدام Webpack هنا .

إليك شكل بنية دليل المشروع:

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

عامة /


سيتم نقل كل شيء في المجلد public/ بشكل ثابت بواسطة الخادم. public/assets/ يحتوي على الصور المستخدمة من قبل مشروعنا.

src /


كل شفرة المصدر موجودة في src/ folder. اسماء 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 هي نقطة الإدخال لعميل Javascript (JS). سيبدأ Webpack من هنا والبحث بشكل متكرر عن الملفات المستوردة الأخرى.
  • سيتم وضع الإخراج JS الخاص بتجميع Webpack الخاص بنا في dist/ directory. سأتصل بهذا الملف حزمة JS الخاصة بنا.
  • نستخدم Babel ، وبصفة خاصة تكوين @ babel / preset-env لنقل رمز JS الخاص بنا للمتصفحات القديمة.
  • نحن نستخدم البرنامج المساعد لاستخراج جميع CSS المشار إليها بواسطة ملفات JS ولجمعها في مكان واحد. سأسميها حزمة CSS الخاصة بنا.

ربما لاحظت أسماء ملفات حزمة غريبة '[name].[contenthash].ext' . أنها تحتوي على استبدال أسماء ملفات Webpack: سيتم استبدال [name] باسم نقطة الإدخال (في حالتنا ، هذه game ) ، وسيتم استبدال [contenthash] لمحتويات الملف. إننا نقوم بذلك من أجل تحسين المشروع للتجزئة - يمكنك إخبار المتصفحات بتخزين مؤقت لحزم JS الخاصة بنا إلى أجل غير مسمى ، لأنه إذا تغيرت الحزمة ، فسيتم تغيير اسم الملف الخاص بها (يتغير contenthash ). ستكون النتيجة النهائية اسم ملف للنموذج game.dbeee76e91a97d0c7207.js .

ملف webpack.common.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.dev.js التطوير ، webpack.prod.js إلى webpack.prod.js لتحسين أحجام الحزمة عند نشرها في الإنتاج.

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


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

 $ 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 و

  <! DOCTYPE html>
 <HTML>
 <رئيس>
   <title> مثال على لعبة .io </title>
   <link type = "text / css" rel = "stylesheet" href = "/ game.bundle.css">
 </ رئيس>
 <body>
   <canvas id = "game-canvas"> </canvas>
   <script async src = "/ game.bundle.js"> </script>
   <div id = "play-menu" class = "hidden">
     <input type = "text" id = "username-input" placeholder = "Username" />
     <button id = "play-button"> PLAY </button>
   </ div>
 </ الهيئة>
 </ HTML> 

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

لدينا:

  • <canvas> HTML5 Canvas ( <canvas> ) الذي سنستخدمه لتقديم اللعبة.
  • <link> لإضافة حزمة CSS الخاصة بنا.
  • <script> لإضافة حزمة Javascript الخاصة بنا.
  • القائمة الرئيسية مع اسم المستخدم <input> <button> "اللعب" ( <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. بعد الانتهاء من الخطوة 3 ، يتم playMenu القائمة الرئيسية ( playMenu ).
  5. تكوين المعالج للضغط على زر اللعب. عند الضغط على الزر ، يقوم الكود بتهيئة اللعبة ويخبر الخادم بأننا مستعدون للعب.

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

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


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

سيكون لدينا ملف 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 ( <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() (من updateDirection() ) عند حدوث حدث إدخال (على سبيل المثال ، عند تحريك الماوس). 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 : الطابع الزمني للخادم للوقت الذي تم فيه إنشاء هذا التحديث.
  • لي : معلومات حول اللاعب الذي يتلقى هذا التحديث.
  • الآخرين : مجموعة من المعلومات حول اللاعبين الآخرين المشاركين في نفس اللعبة.
  • الرصاص : مجموعة من المعلومات حول الأصداف الموجودة في اللعبة.
  • المتصدرين : بيانات المتصدرين الحالية. في هذا المنشور ، لن نأخذها في الاعتبار.

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 في الثانية):


لسوء الحظ ، لا يوجد شيء مثالي. ستكون الصورة الأكثر واقعية هي:

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

7.2 تحسين حالة العملاء


سنقوم بإجراء بعض التحسينات على التنفيذ الساذج. أولاً ، نستخدم تأخير تقديم قدره 100 مللي ثانية. هذا يعني أن الحالة "الحالية" للعميل ستتخلف دائمًا عن حالة اللعبة على الخادم بمقدار 100 مللي ثانية. على سبيل المثال ، إذا كان الوقت على الخادم هو 150 ، فسيتم عرض الحالة التي كان الخادم فيها في الوقت 50 على العميل:


هذا يعطينا مؤقتًا قدره 100 مللي ثانية ، مما يسمح لنا بالبقاء على قيد الحياة في الوقت غير المتوقع لتلقي تحديثات اللعبة:


الدفع مقابل هذا سيكون تأخير إدخال مستمر (تأخر الإدخال) قدره 100 مللي ثانية. هذه تضحية بسيطة من أجل اللعب السلس - معظم اللاعبين (خاصة غير العاديين) لن يلاحظوا هذا التأخير. من الأسهل بالنسبة للناس التكيف مع تأخير ثابت قدره 100 مللي ثانية بدلاً من التأخير غير المتوقع.

يمكننا استخدام تقنية أخرى تسمى "التنبؤ من جانب العميل" ، والتي تقوم بعمل جيد في تقليل التأخيرات المتوقعة ، لكن لن يتم أخذها في الاعتبار في هذا المنشور.

تحسين آخر نستخدمه هو الاستيفاء الخطي . نظرًا للتأخير في التقديم ، عادة ما نتجاوز الوقت الحالي في العميل عن طريق تحديث واحد على الأقل. عند getCurrentState() ، يمكننا إجراء الاستيفاء الخطي بين تحديثات اللعبة فورًا وبعد الوقت الحالي في العميل:


هذا يحل المشكلة مع معدل الإطار: الآن يمكننا تقديم إطارات فريدة مع أي تردد نحتاجه!

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. بعد ذلك ، للتحقق من استخدام الذاكرة ، نقوم بحذف جميع التحديثات القديمة إلى التحديث الأساسي ، لأننا لم نعد بحاجة إليها.

ما هو "التحديث الأساسي"؟ هذا هو التحديث الأول الذي نجده يتحرك للخلف من وقت الخادم الحالي . تذكر هذه الدائرة؟


«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 Github .

2. -


Node.js, .io .

1.


- - Node.js Express . 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. :


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») – ! , Game .

2. Game


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 , . ID .

, Game :

  • sockets — , ID , . ID .
  • players — , ID code>Player

bulletsهي مجموعة من الكائنات Bulletالتي ليس لها ترتيب محدد.
lastUpdateTime- هذا هو الطابع الزمني لآخر مرة تم فيها تحديث اللعبة. قريبا سنرى كيف يتم استخدامه.
shouldSendUpdateهو متغير مساعد. سوف نرى استخدامه قريبا جدا.
الأساليب addPlayer()، removePlayer()وليس handleInput()هناك حاجة لشرح ، يتم استخدامها في server.js. إذا كنت بحاجة إلى تحديث ذاكرتك ، فارجع إلى أعلى قليلاً. يبدأ

السطر الأخير في دورة تحديث اللعبة (بتردد 60 تحديث / تحديثات):constructor()

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 :

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

, , . , :


:

  • . , bullet.parentID player.id .
  • . break : , , .


! , - .io. ما التالي؟ بناء لعبة .io الخاصة بك!

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

Source: https://habr.com/ru/post/ar450574/


All Articles