دليل شامل: بناء لعبة بطاقات متعددة اللاعبين باستخدام Phaser 3 و Express و Socket.IO

دقائق القراءة: 17

يُعد تطوير الألعاب الرقمية تجربة شيقة، خاصةً عندما يتعلق الأمر بتحويل ألعاب الطاولة التقليدية إلى صيغ تفاعلية متعددة اللاعبين. في هذا الدليل، سنخوض رحلة بناء لعبة بطاقات بسيطة ومتعددة اللاعبين، مستخدمين ثلاث تقنيات قوية: مكتبة Phaser 3 لواجهة اللعبة، إطار عمل Express لإنشاء الخادم الخلفي، ومكتبة Socket.IO لتوفير الاتصال اللحظي بين اللاعبين.

للبدء، ستحتاج إلى التأكد من تثبيت Node.js و NPM (مدير حزم Node) و Git وتكوينها على جهازك. يُفضل أيضًا أن تكون لديك بعض الخبرة في لغة JavaScript، وقد يكون من المفيد مراجعة البرنامج التعليمي الأساسي لـ Phaser قبل الغوص في هذا المشروع.

نود أن نتوجه بالشكر الجزيل إلى سكوت ويستوفر لبرنامجه التعليمي حول هذا الموضوع، وكذلك إلى Kal_Torak ومجتمع Phaser لإجاباتهم على جميع استفساراتنا، وصديقنا مايك لمساعدته في تصور بنية هذا المشروع.

ملاحظة: سنستخدم الأصول والألوان من لعبة بطاقات الطاولة الخاصة بي، Entromancy: Hacker Battles. إذا كنت تفضل ذلك، يمكنك استخدام صورك الخاصة (أو حتى مستطيلات Phaser) وألوانك. يمكنك الوصول إلى كود المشروع كاملاً على GitHub.

فكرة اللعبة: الأساسيات والتصميم

ستتميز لعبة البطاقات البسيطة التي نبنيها بوجود عميل Phaser يتولى معظم منطق اللعبة، مثل توزيع البطاقات وتوفير وظائف السحب والإفلات (drag-and-drop)، وغير ذلك. في الواجهة الخلفية، سنقوم بتشغيل خادم Express يستخدم Socket.IO للتواصل بين العملاء، مما يضمن أنه عندما يلعب لاعب بطاقة، تظهر هذه البطاقة في واجهة العميل للاعب الآخر، والعكس صحيح.

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

تطوير الواجهة الأمامية (العميل)

إعداد بيئة العمل للعميل

لبناء هيكل عميلنا، سنقوم باستنساخ قالب مشروع Phaser 3 Webpack شبه الرسمي من GitHub. افتح واجهة سطر الأوامر المفضلة لديك وأنشئ مجلدًا جديدًا:

mkdir multiplayer-card-project
cd multiplayer-card-project

الآن، قم باستنساخ مشروع git:

git clone https://github.com/photonstorm/phaser3-project-template.git

سيقوم هذا الأمر بتنزيل القالب في مجلد يسمى "phaser3-project-template" داخل مجلد /multiplayer-card-project. إذا كنت ترغب في متابعة بنية ملفات برنامجنا التعليمي، فقم بتغيير اسم مجلد القالب هذا إلى "client". انتقل إلى هذا الدليل الجديد وقم بتثبيت جميع التبعيات:

cd client
npm install

يجب أن تبدو بنية مجلد مشروعك الآن كما يلي:

بنية ملفات المشروع الأولية بعد استنساخ قالب Phaser 3

تشغيل العميل وإعداد الملفات

قبل أن نبدأ بتعديل الملفات، لنعد إلى واجهة سطر الأوامر (CLI) وندخل الأمر التالي في مجلد /client:

npm start

يستخدم قالب Phaser الخاص بنا Webpack لتشغيل خادم محلي يقوم بدوره بتقديم تطبيق لعبة بسيط في متصفحنا (عادةً على http://localhost:8080). رائع!

الآن، افتح مشروعك في محرر الأكواد المفضل لديك وقم بإجراء بعض التغييرات لتناسب لعبة البطاقات الخاصة بنا:

  1. احذف كل شيء في مجلد /client/src/assets واستبدله بصور البطاقات من GitHub.
  2. في دليل /client/src، أضف مجلدًا يسمى "scenes" وآخر يسمى "helpers".
  3. في /client/src/scenes، أضف ملفًا فارغًا يسمى "game.js".
  4. في /client/src/helpers، أضف ثلاثة ملفات فارغة: "card.js"، "dealer.js"، و "zone.js".

يجب أن تبدو بنية مشروعك الآن كما يلي:

بنية ملفات المشروع بعد إضافة مجلدات ومستندات المشاهد والمساعدات

قد تظهر لك بعض الأخطاء في العميل لأننا حذفنا بعض الأشياء، لكن لا تقلق، سنقوم بإصلاحها.

تكوين نقطة الدخول الرئيسية (index.js)

افتح ملف /src/index.js، وهو نقطة الدخول الرئيسية لتطبيق الواجهة الأمامية لدينا. أدخل الكود التالي:

import Phaser from "phaser";
import Game from "./scenes/game";

const config = {
  type: Phaser.AUTO,
  parent: "phaser-example",
  width: 1280,
  height: 780,
  scene: [ Game ]
};

const game = new Phaser.Game(config);

كل ما فعلناه هنا هو إعادة هيكلة الكود الأساسي (boilerplate) للاستفادة من نظام “المشاهد” (scene) في Phaser، مما يسمح لنا بفصل مشاهد اللعبة بدلاً من محاولة حشر كل شيء في ملف واحد. يمكن أن تكون المشاهد مفيدة إذا كنت تقوم بإنشاء عوالم ألعاب متعددة، أو بناء شاشات تعليمات، أو بشكل عام محاولة الحفاظ على تنظيم الكود.

بناء مشهد اللعبة (game.js)

لننتقل إلى ملف /src/scenes/game.js ونكتب بعض الكود:

export default class Game extends Phaser.Scene {
  constructor() {
    super({ key: 'Game' });
  }

  preload() {
    this.load.image('cyanCardFront', 'src/assets/CyanCardFront.png');
    this.load.image('cyanCardBack', 'src/assets/CyanCardBack.png');
    this.load.image('magentaCardFront', 'src/assets/MagentaCardFront.png');
    this.load.image('magentaCardBack', 'src/assets/MagentaCardBack.png');
  }

  create() {
    this.dealText = this.add.text(75, 350, ['DEAL CARDS'])
      .setFontSize(18)
      .setFontFamily('Trebuchet MS')
      .setColor('#00ffff')
      .setInteractive();
  }

  update() {
  }
}

نحن نستفيد من فئات ES6 لإنشاء مشهد لعبة جديد، والذي يتضمن دوال preload() و create() و update():

  • تُستخدم دالة preload() لتحميل أي أصول سنستخدمها في لعبتنا مسبقًا.
  • تُشغل دالة create() عند بدء اللعبة، وهي المكان الذي سنقوم فيه بإنشاء معظم واجهة المستخدم ومنطق اللعبة.
  • تُستدعى دالة update() مرة واحدة لكل إطار، ولن نستخدمها في برنامجنا التعليمي هذا (ولكنها قد تكون مفيدة في لعبتك الخاصة اعتمادًا على متطلباتها).

داخل دالة create()، أنشأنا جزءًا من النص يقول "DEAL CARDS" وجعلناه تفاعليًا:

نص 'DEAL CARDS' التفاعلي في واجهة اللعبة

إضافة التفاعل الأساسي للبطاقات

لننشئ بعض الأكواد المؤقتة لفهم كيفية عمل هذا كله بمجرد تشغيله. أضف ما يلي إلى دالة create() الخاصة بك:

    let self = this;

    this.card = this.add.image(300, 300, 'cyanCardFront').setScale(0.3, 0.3).setInteractive();
    this.input.setDraggable(this.card);

    this.dealCards = () => {
    }

    this.dealText.on('pointerdown', function () {
      self.dealCards();
    })

    this.dealText.on('pointerover', function () {
      self.dealText.setColor('#ff69b4');
    })

    this.dealText.on('pointerout', function () {
      self.dealText.setColor('#00ffff');
    })

    this.input.on('drag', function (pointer, gameObject, dragX, dragY) {
      gameObject.x = dragX;
      gameObject.y = dragY;
    })

لقد أضفنا الكثير من الهيكل، ولكن لم يحدث الكثير بعد. الآن، عندما يحوم مؤشر الماوس فوق نص "DEAL CARDS"، يتم تمييزه باللون الوردي الساخن، وهناك بطاقة عشوائية على شاشتنا:

بطاقة عشوائية تظهر على الشاشة مع نص 'DEAL CARDS' الملون

لقد وضعنا الصورة عند الإحداثيات (x, y) وهي (300, 300)، وضبطنا مقياسها لتكون أصغر قليلاً، وجعلناها تفاعلية وقابلة للسحب. أضفنا أيضًا القليل من المنطق لتحديد ما يجب أن يحدث عند السحب: يجب أن تتبع البطاقة إحداثيات (x, y) للماوس.

لقد أنشأنا أيضًا دالة dealCards() فارغة سيتم استدعاؤها عندما ننقر على نص "DEAL CARDS". بالإضافة إلى ذلك، قمنا بحفظ الكائن this – الذي يشير إلى المشهد الذي نعمل فيه حاليًا – في متغير يسمى "self" حتى نتمكن من استخدامه في جميع دوالنا دون القلق بشأن نطاق المتغيرات (scope).

تنظيم الكود باستخدام فئة البطاقة (card.js)

سيصبح مشهد اللعبة (Game scene) فوضويًا بسرعة إذا لم نبدأ في نقل الأشياء، لذا لنحذف كتلة الكود التي تبدأ بـ "this.card" وننتقل إلى /src/helpers/card.js لكتابة ما يلي:

export default class Card {
  constructor (scene) {
    this.render = (x, y, sprite) => {
      let card = scene.add.image(x, y, sprite).setScale(0.3, 0.3).setInteractive();
      scene.input.setDraggable(card);
      return card;
    }
  }
}

لقد أنشأنا فئة جديدة تقبل مشهدًا كمعامل، وتتميز بدالة render() التي تقبل إحداثيات (x, y) و sprite (صورة). الآن، يمكننا استدعاء هذه الدالة من أي مكان آخر وتمرير المعاملات الضرورية لإنشاء البطاقات. لنستورد فئة Card في الجزء العلوي من مشهد اللعبة (Game scene):

import Card from '../helpers/card';

وأدخل الكود التالي داخل دالة dealCards() الفارغة لدينا:

    this.dealCards = () => {
      for (let i = 0; i < 5; i++) {
        let playerCard = new Card(this);
        playerCard.render(475 + (i * 100), 650, 'cyanCardFront');
      }
    }

عندما ننقر على زر "DEAL CARDS"، نقوم الآن بالتكرار عبر حلقة for تنشئ البطاقات وتعرضها بالتتابع على الشاشة:

خمس بطاقات متتالية تظهر على الشاشة بعد النقر على 'DEAL CARDS'

رائع! يمكننا سحب هذه البطاقات حول الشاشة، ولكن قد يكون من الجيد تحديد المكان الذي يمكن إسقاطها فيه لدعم منطق لعبتنا. لننتقل إلى /src/helpers/zone.js ونضيف فئة جديدة:

إنشاء مناطق الإسقاط (zone.js)

export default class Zone {
  constructor (scene) {
    this.renderZone = () => {
      let dropZone = scene.add.zone(700, 375, 900, 250).setRectangleDropZone(900, 250);
      dropZone.setData({ cards: 0 });
      return dropZone;
    };

    this.renderOutline = (dropZone) => {
      let dropZoneOutline = scene.add.graphics();
      dropZoneOutline.lineStyle(4, 0xff69b4);
      dropZoneOutline.strokeRect(dropZone.x - dropZone.input.hitArea.width / 2, dropZone.y - dropZone.input.hitArea.height / 2, dropZone.input.hitArea.width, dropZone.input.hitArea.height)
    }
  }
}

يحتوي Phaser على مناطق إسقاط (dropzones) مدمجة تسمح لنا بتحديد مكان إسقاط كائنات اللعبة، وقد قمنا بإعداد واحدة هنا وزودناها بمخطط تفصيلي (outline). أضفنا أيضًا القليل من البيانات تسمى "cards" إلى منطقة الإسقاط، والتي سنستخدمها لاحقًا. لنستورد منطقتنا الجديدة إلى مشهد اللعبة (Game scene):

import Zone from '../helpers/zone';

واستدعِها داخل دالة create():

    this.zone = new Zone(this);
    this.dropZone = this.zone.renderZone();
    this.outline = this.zone.renderOutline(this.dropZone);

ليس سيئًا!

منطقة إسقاط البطاقات المحددة بمخطط وردي في منتصف الشاشة

إضافة منطق الإسقاط للبطاقات

نحتاج إلى إضافة بعض المنطق لتحديد كيفية إسقاط البطاقات في المنطقة. لنفعل ذلك أسفل دالة "this.input.on('drag')":

    this.input.on('dragstart', function (pointer, gameObject) {
      gameObject.setTint(0xff69b4);
      self.children.bringToTop(gameObject);
    })

    this.input.on('dragend', function (pointer, gameObject, dropped) {
      gameObject.setTint();
      if (!dropped) {
        gameObject.x = gameObject.input.dragStartX;
        gameObject.y = gameObject.input.dragStartY;
      }
    })

    this.input.on('drop', function (pointer, gameObject, dropZone) {
      dropZone.data.values.cards++;
      gameObject.x = (dropZone.x - 350) + (dropZone.data.values.cards * 50);
      gameObject.y = dropZone.y;
      gameObject.disableInteractive();
    })

بدءًا من الجزء السفلي من الكود، عندما يتم إسقاط بطاقة، نقوم بزيادة قيمة البيانات "cards" في منطقة الإسقاط، ونعيّن إحداثيات (x, y) للبطاقة إلى منطقة الإسقاط بناءً على عدد البطاقات الموجودة بالفعل فيها. نقوم أيضًا بتعطيل تفاعلية البطاقات بعد إسقاطها حتى لا يمكن سحبها مرة أخرى:

بطاقة تم إسقاطها بنجاح داخل منطقة الإسقاط

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

تطوير الواجهة الخلفية (الخادم)

إعداد بيئة العمل للخادم

لنفتح واجهة سطر أوامر جديدة في دليلنا الرئيسي (فوق /client) ونكتب:

npm init
npm install --save express socket.io nodemon

لقد قمنا بتهيئة ملف package.json جديد وقمنا بتثبيت Express و Socket.IO و Nodemon (الذي سيراقب خادمنا ويعيد تشغيله عند حدوث تغييرات). في محرر الأكواد الخاص بنا، لنغير قسم "scripts" في ملف package.json ليصبح كما يلي:

  "scripts": {
    "start": "nodemon server.js"
  },

ممتاز. نحن جاهزون لتجميع خادمنا! أنشئ ملفًا فارغًا يسمى "server.js" في دليلنا الرئيسي وأدخل الكود التالي:

بناء الخادم الأساسي (server.js)

const server = require('express')();
const http = require('http').createServer(server);
const io = require('socket.io')(http);

io.on('connection', function (socket) {
  console.log('A user connected: ' + socket.id);

  socket.on('disconnect', function () {
    console.log('A user disconnected: ' + socket.id);
  });
});

http.listen(3000, function () {
  console.log('Server started!');
});

نقوم هنا باستيراد Express و Socket.IO، ونطلب من الخادم الاستماع على المنفذ 3000. عندما يتصل عميل بهذا المنفذ أو ينفصل عنه، سنقوم بتسجيل الحدث في وحدة التحكم (console) مع معرف socket الخاص بالعميل. افتح واجهة سطر أوامر جديدة وابدأ الخادم:

npm run start

يجب أن يعمل خادمنا الآن على localhost:3000، وسيراقب Nodemon ملفات الواجهة الخلفية لدينا بحثًا عن أي تغييرات. لن يحدث الكثير باستثناء تسجيل رسالة "Server started!" في وحدة التحكم.

ربط العميل بالخادم عبر Socket.IO

في واجهة سطر الأوامر الأخرى المفتوحة، لنعد إلى دليل /client ونثبت إصدار العميل من Socket.IO:

cd client
npm install --save socket.io-client

يمكننا الآن استيراده في مشهد اللعبة (Game scene) الخاص بنا:

import io from 'socket.io-client';

رائع! لقد قمنا تقريبًا بربط واجهتنا الأمامية والخلفية. كل ما نحتاج إليه هو كتابة بعض الكود في دالة create():

    this.socket = io('http://localhost:3000');

    this.socket.on('connect', function () {
      console.log('Connected!');
    });

نقوم بتهيئة متغير "socket" جديد يشير إلى منفذنا المحلي 3000 ويسجل في وحدة تحكم المتصفح عند الاتصال. افتح وأغلق بعض المتصفحات على http://localhost:8080 (حيث يتم تقديم عميل Phaser الخاص بنا) ويجب أن ترى ما يلي في واجهة سطر الأوامر الخاصة بك:

رسائل الاتصال والانفصال في وحدة تحكم الخادم

ممتاز! لنبدأ بإضافة منطق إلى ملف server.js لتلبية احتياجات لعبة البطاقات الخاصة بنا. استبدل الكود الحالي بما يلي:

منطق الخادم للعب المتعدد

const server = require('express')();
const http = require('http').createServer(server);
const io = require('socket.io')(http);

let players = [];

io.on('connection', function (socket) {
  console.log('A user connected: ' + socket.id);
  players.push(socket.id);

  if (players.length === 1) {
    io.emit('isPlayerA');
  };

  socket.on('dealCards', function () {
    io.emit('dealCards');
  });

  socket.on('cardPlayed', function (gameObject, isPlayerA) {
    io.emit('cardPlayed', gameObject, isPlayerA);
  });

  socket.on('disconnect', function () {
    console.log('A user disconnected: ' + socket.id);
    players = players.filter(player => player !== socket.id);
  });
});

http.listen(3000, function () {
  console.log('Server started!');
});

لقد قمنا بتهيئة مصفوفة فارغة تسمى "players" ونضيف معرف socket إليها في كل مرة يتصل فيها عميل بالخادم، بينما نقوم أيضًا بحذف معرف socket عند الانفصال. إذا كان العميل هو أول من يتصل بالخادم، فإننا نطلب من Socket.IO أن "emit" (يُرسل) حدثًا يفيد بأنه سيكون اللاعب A. بعد ذلك، عندما يتلقى الخادم حدثًا يسمى "dealCards" أو "cardPlayed"، يجب أن يُرسل (emit) مرة أخرى إلى العملاء بأنه يجب عليهم التحديث وفقًا لذلك. صدق أو لا تصدق، هذا هو كل الكود الذي نحتاجه لتشغيل خادمنا!

تطبيق منطق اللعب المتعدد على العميل

لنعد انتباهنا إلى مشهد اللعبة (Game scene). في الجزء العلوي من دالة create()، اكتب ما يلي:

    this.isPlayerA = false;
    this.opponentCards = [];

أسفل كتلة الكود التي تبدأ بـ "this.socket.on(connect)"، اكتب:

    this.socket.on('isPlayerA', function () {
      self.isPlayerA = true;
    })

الآن، إذا كان عميلنا هو أول من يتصل بالخادم، فسيُرسل الخادم حدثًا يخبر العميل بأنه سيكون اللاعب A. يتلقى socket العميل هذا الحدث ويحول قيمة المتغير المنطقي "isPlayerA" من false إلى true.

ملاحظة: من هذه النقطة فصاعدًا، قد تحتاج إلى إعادة تحميل صفحة المتصفح يدويًا (المعينة إلى http://localhost:8080)، بدلاً من أن يقوم Webpack بذلك تلقائيًا، لكي ينفصل العميل ويتصل بالخادم بشكل صحيح.

توزيع البطاقات بذكاء باستخدام فئة الموزع (dealer.js)

نحتاج إلى إعادة تكوين منطق دالة dealCards() لدعم جانب اللعب المتعدد في لعبتنا، نظرًا لأننا نريد أن يقوم العميل بتوزيع مجموعة معينة من البطاقات قد تختلف عن بطاقات خصمنا. بالإضافة إلى ذلك، نريد عرض ظهور بطاقات خصمنا على شاشتنا، والعكس صحيح. سننتقل إلى ملف /src/helpers/dealer.js الفارغ، ونستورد card.js، وننشئ فئة جديدة:

import Card from './card';

export default class Dealer {
  constructor (scene) {
    this.dealCards = () => {
      let playerSprite;
      let opponentSprite;
      if (scene.isPlayerA) {
        playerSprite = 'cyanCardFront';
        opponentSprite = 'magentaCardBack';
      } else {
        playerSprite = 'magentaCardFront';
        opponentSprite = 'cyanCardBack';
      };
      for (let i = 0; i < 5; i++) {
        let playerCard = new Card(scene);
        playerCard.render(475 + (i * 100), 650, playerSprite);
        let opponentCard = new Card(scene);
        scene.opponentCards.push(opponentCard.render(475 + (i * 100), 125, opponentSprite).disableInteractive());
      }
    }
  }
}

باستخدام هذه الفئة الجديدة، نتحقق مما إذا كان العميل هو اللاعب A، ونحدد ما هي الصور (sprites) التي يجب استخدامها في كلتا الحالتين. ثم نقوم بتوزيع البطاقات على عميلنا، بينما نعرض ظهور بطاقات خصمنا في الجزء العلوي من الشاشة ونضيفها إلى مصفوفة opponentCards التي قمنا بتهيئتها في مشهد اللعبة (Game scene) الخاص بنا.

في ملف /src/scenes/game.js، استورد فئة Dealer:

import Dealer from '../helpers/dealer';

ثم استبدل دالة dealCards() الخاصة بنا بما يلي:

    this.dealer = new Dealer(this);

أسفل كتلة الكود التي تبدأ بـ "this.socket.on('isPlayerA')"، أضف ما يلي:

    this.socket.on('dealCards', function () {
      self.dealer.dealCards();
      self.dealText.disableInteractive();
    })

نحتاج أيضًا إلى تحديث دالة dealText لتتوافق مع هذه التغييرات:

    this.dealText.on('pointerdown', function () {
      self.socket.emit("dealCards");
    })

لقد أنشأنا فئة Dealer جديدة ستتولى توزيع البطاقات لنا وعرض بطاقات خصمنا على الشاشة. عندما يتلقى socket العميل حدث "dealCards" من الخادم، فإنه سيستدعي دالة dealCards() من هذه الفئة الجديدة، ويعطل dealText حتى لا نتمكن من الاستمرار في إنشاء البطاقات دون سبب. أخيرًا، قمنا بتغيير وظيفة dealText بحيث عند الضغط عليها، يُرسل العميل حدثًا إلى الخادم بأننا نريد توزيع البطاقات، مما يربط كل شيء معًا.

شغّل متصفحين منفصلين يشيران إلى http://localhost:8080 واضغط على "DEAL CARDS" في أحدهما. يجب أن ترى صورًا مختلفة على كل شاشة:

واجهة العميل الأول بعد توزيع البطاقات، تظهر بطاقات اللاعب الخاصة به وظهور بطاقات الخصم
واجهة العميل الثاني بعد توزيع البطاقات، تظهر بطاقات اللاعب الخاصة به وظهور بطاقات الخصم

ملاحظة مرة أخرى: إذا واجهت مشكلات في هذه الخطوة، فقد تضطر إلى إغلاق أحد المتصفحات وإعادة تحميل الأول لضمان انفصال كلا العميلين عن الخادم، والذي يجب تسجيله في وحدة تحكم سطر الأوامر الخاصة بك.

مزامنة لعب البطاقات بين اللاعبين

ما زلنا بحاجة إلى معرفة كيفية عرض بطاقاتنا المسقطة في عميل خصمنا، والعكس صحيح. يمكننا القيام بكل ذلك في مشهد لعبتنا! قم بتحديث كتلة الكود التي تبدأ بـ "this.input.on('drop')" بإضافة سطر واحد في النهاية:

    this.input.on('drop', function (pointer, gameObject, dropZone) {
      dropZone.data.values.cards++;
      gameObject.x = (dropZone.x - 350) + (dropZone.data.values.cards * 50);
      gameObject.y = dropZone.y;
      gameObject.disableInteractive();
      self.socket.emit('cardPlayed', gameObject, self.isPlayerA);
    })

عندما يتم إسقاط بطاقة في عميلنا، سيُرسل socket حدثًا يسمى "cardPlayed"، ممررًا تفاصيل كائن اللعبة (game object) وقيمة المتغير المنطقي isPlayerA الخاص بالعميل (الذي يمكن أن يكون true أو false، اعتمادًا على ما إذا كان العميل هو أول من اتصل بالخادم).

تذكر أنه في كود خادمنا، يتلقى Socket.IO ببساطة حدث "cardPlayed" ويُرسل نفس الحدث مرة أخرى إلى جميع العملاء، ممررًا نفس المعلومات حول كائن اللعبة و isPlayerA من العميل الذي بدأ الحدث.

لنكتب ما يجب أن يحدث عندما يتلقى العميل حدث "cardPlayed" من الخادم، أسفل كتلة الكود "this.socket.on('dealCards')":

    this.socket.on('cardPlayed', function (gameObject, isPlayerA) {
      if (isPlayerA !== self.isPlayerA) {
        let sprite = gameObject.textureKey;
        self.opponentCards.shift().destroy();
        self.dropZone.data.values.cards++;
        let card = new Card(self);
        card.render(((self.dropZone.x - 350) + (self.dropZone.data.values.cards * 50)), (self.dropZone.y), sprite).disableInteractive();
      }
    })

تقارن كتلة الكود أولاً المتغير المنطقي isPlayerA الذي تتلقاه من الخادم بقيمة isPlayerA الخاصة بالعميل نفسه، وهو فحص لتحديد ما إذا كان العميل الذي يتلقى الحدث هو نفسه الذي أنشأه. دعنا نفكر في ذلك بشكل أعمق، لأنه يكشف عن مكون رئيسي لكيفية عمل علاقة العميل بالخادم، باستخدام Socket.IO كالموصل.

لنفترض أن العميل A يتصل بالخادم أولاً، ويُخبر من خلال حدث "isPlayerA" بأنه يجب أن يغير قيمة isPlayerA الخاصة به إلى true. سيحدد ذلك نوع البطاقات التي ينشئها عندما ينقر المستخدم على "DEAL CARDS" من خلال هذا العميل. إذا اتصل العميل B بالخادم ثانيًا، فلن يُخبر أبدًا بتعديل قيمة isPlayerA الخاصة به، والتي تظل false. سيحدد ذلك أيضًا نوع البطاقات التي ينشئها.

عندما يسقط العميل A بطاقة، فإنه يُرسل حدث "cardPlayed" إلى الخادم، ممررًا معلومات حول البطاقة التي تم إسقاطها، وقيمة isPlayerA الخاصة به، وهي true. ثم يقوم الخادم بإعادة توجيه جميع هذه المعلومات إلى جميع العملاء بحدث "cardPlayed" الخاص به.

يتلقى العميل A هذا الحدث من الخادم، ويلاحظ أن قيمة isPlayerA من الخادم هي true، مما يعني أن الحدث قد تم إنشاؤه بواسطة العميل A نفسه. لا يحدث شيء خاص. يتلقى العميل B نفس الحدث من الخادم، ويلاحظ أن قيمة isPlayerA من الخادم هي true، على الرغم من أن قيمة isPlayerA الخاصة بالعميل B هي false. بسبب هذا الاختلاف، فإنه يقوم بتنفيذ بقية كتلة الكود.

يقوم الكود اللاحق بتخزين "textureKey" - وهي أساسًا الصورة - لكائن اللعبة الذي يتلقاه من الخادم في متغير يسمى "sprite". يقوم بتدمير أحد ظهور بطاقات الخصم المعروضة في الجزء العلوي من الشاشة، ويزيد قيمة البيانات "cards" في منطقة الإسقاط حتى نتمكن من الاستمرار في وضع البطاقات من اليسار إلى اليمين. ثم يقوم الكود بإنشاء بطاقة جديدة في منطقة الإسقاط تستخدم متغير sprite لإنشاء نفس البطاقة التي تم إسقاطها في العميل الآخر (إذا كانت لديك بيانات مرفقة بكائن اللعبة هذا، يمكنك استخدام نهج مماثل لإرفاقها هنا أيضًا).

يجب أن يبدو كود ملف /src/scenes/game.js النهائي الخاص بك كما يلي:

import io from 'socket.io-client';
import Card from '../helpers/card';
import Dealer from "../helpers/dealer";
import Zone from '../helpers/zone';

export default class Game extends Phaser.Scene {
  constructor() {
    super({ key: 'Game' });
  }

  preload() {
    this.load.image('cyanCardFront', 'src/assets/CyanCardFront.png');
    this.load.image('cyanCardBack', 'src/assets/CyanCardBack.png');
    this.load.image('magentaCardFront', 'src/assets/magentaCardFront.png');
    this.load.image('magentaCardBack', 'src/assets/magentaCardBack.png');
  }

  create() {
    this.isPlayerA = false;
    this.opponentCards = [];

    this.zone = new Zone(this);
    this.dropZone = this.zone.renderZone();
    this.outline = this.zone.renderOutline(this.dropZone);

    this.dealer = new Dealer(this);

    let self = this;

    this.socket = io('http://localhost:3000');

    this.socket.on('connect', function () {
      console.log('Connected!');
    });

    this.socket.on('isPlayerA', function () {
      self.isPlayerA = true;
    })

    this.socket.on('dealCards', function () {
      self.dealer.dealCards();
      self.dealText.disableInteractive();
    })

    this.socket.on('cardPlayed', function (gameObject, isPlayerA) {
      if (isPlayerA !== self.isPlayerA) {
        let sprite = gameObject.textureKey;
        self.opponentCards.shift().destroy();
        self.dropZone.data.values.cards++;
        let card = new Card(self);
        card.render(((self.dropZone.x - 350) + (self.dropZone.data.values.cards * 50)), (self.dropZone.y), sprite).disableInteractive();
      }
    })

    this.dealText = this.add.text(75, 350, ['DEAL CARDS'])
      .setFontSize(18)
      .setFontFamily('Trebuchet MS')
      .setColor('#00ffff')
      .setInteractive();

    this.dealText.on('pointerdown', function () {
      self.socket.emit("dealCards");
    })

    this.dealText.on('pointerover', function () {
      self.dealText.setColor('#ff69b4');
    })

    this.dealText.on('pointerout', function () {
      self.dealText.setColor('#00ffff');
    })

    this.input.on('drag', function (pointer, gameObject, dragX, dragY) {
      gameObject.x = dragX;
      gameObject.y = dragY;
    })

    this.input.on('dragstart', function (pointer, gameObject) {
      gameObject.setTint(0xff69b4);
      self.children.bringToTop(gameObject);
    })

    this.input.on('dragend', function (pointer, gameObject, dropped) {
      gameObject.setTint();
      if (!dropped) {
        gameObject.x = gameObject.input.dragStartX;
        gameObject.y = gameObject.input.dragStartY;
      }
    })

    this.input.on('drop', function (pointer, gameObject, dropZone) {
      dropZone.data.values.cards++;
      gameObject.x = (dropZone.x - 350) + (dropZone.data.values.cards * 50);
      gameObject.y = dropZone.y;
      gameObject.disableInteractive();
      self.socket.emit('cardPlayed', gameObject, self.isPlayerA);
    })
  }

  update() {
  }
}

احفظ كل شيء، افتح متصفحين، واضغط على "DEAL CARDS". عندما تسحب وتسقط بطاقة في أحد العملاء، يجب أن تظهر في منطقة الإسقاط للعميل الآخر، بينما يتم أيضًا حذف ظهر بطاقة، مما يدل على أنه تم لعب بطاقة:

بطاقة مسقطة تظهر في منطقة اللعب للعميل الأول
بطاقة مسقطة تظهر في منطقة اللعب للعميل الثاني

هذا كل شيء! يجب أن يكون لديك الآن قالب وظيفي للعبة بطاقاتك متعددة اللاعبين، والذي يمكنك استخدامه لإضافة بطاقاتك وفنونك ومنطق لعبتك الخاص. يمكن أن تكون الخطوة الأولى هي إضافة المزيد إلى فئة Dealer الخاصة بك عن طريق جعلها تخلط مصفوفة من البطاقات وتعيد بطاقة عشوائية (تلميح: تحقق من Phaser.Math.RND.shuffle([array])).

أتمنى لك برمجة سعيدة!

الخلاصة التقنية

يُقدم هذا المشروع نموذجًا عمليًا وقويًا لتطوير ألعاب الويب متعددة اللاعبين، مستفيدًا من قوة Phaser 3 لرسوميات الواجهة التفاعلية، و Express.js لإدارة الخادم بكفاءة، و Socket.IO للتواصل اللحظي الفعال بين اللاعبين. يبرز التصميم المعياري باستخدام الفئات (Classes) في JavaScript كأفضل ممارسة لتنظيم الكود وقابلية التوسع، مما يسهل إضافة ميزات جديدة أو تعديل المنطق الحالي. يُعد هذا الدليل نقطة انطلاق ممتازة للمطورين الطموحين في عالم تطوير الألعاب، حيث يوفر فهمًا عميقًا لكيفية بناء أساس متين لتجارب لعب جماعية تفاعلية على الويب.

اترك تعليقاً

لن يتم نشر عنوان بريدك الإلكتروني. الحقول الإلزامية مشار إليها بـ *