اذهب إلى المحتوى

أمثلة برمجية على الشبكات في جافا: إطار عمل لتطوير الألعاب عبر الشبكة


رضوى العربي

سنناقش بهذا المقال مجموعةً من البرامج التي تَستخدِم الشبكات والخيوط. تتشارك تلك التطبيقات بمشكلة توفير الاتصال الشبكي بين مجموعةٍ من البرامج المُشغَّلة على حواسيبٍ مختلفة. تُعدّ الألعاب ثنائية اللاعبين أو متعددة اللاعبين عبر الشبكة واحدةً من الأمثلة النموذجية على هذا النوع من التطبيقات، ولكن تضطّر تطبيقاتٌ أخرى أكثر جدية لمواجهة نفس المشكلة أيضًا. سندرس بالقسم الأول من هذا المقال إطار عملٍ framework يُمكِن اِستخدامه ببرامج مختلفة تندرج تحت هذا النوع، ثم سنناقش بالجزء المتبقي من هذا المقال ثلاثة تطبيقات مبنيةً على هذا الإطار. في الواقع، قد تكون تلك التطبيقات هي الأكثر تعقيدًا بهذه السلسلة، ولذلك فهمها ليس ضروريًا لفهم أساسيات الشبكات.

في الواقع، يعود الفضل لهذا المقال إلى الطالبين Alexander Kittelberger و Kieran Koehnlein؛ فقد أرادا أن يَكتُبا برنامج لعبة بوكر عبر الشبكة مشروعًا نهائيًا بالصف الذي كان الكاتب يُدرسّه. لقد ساعدهما الكاتب بالجزء المُتعلّق بالشبكات بكتابة إطار عملٍ بسيط يدعم الاتصال بين اللاعبين. يتعرَّض التطبيق إلى الكثير من الأفكار الهامة، ولذلك تقررت إضافة إصدارٍ أعم وأكثر تطورًا من إطار العمل إلى السلسلة. يمثّل المثال الأخير بهذا المقال برنامج لعبة بوكر عبر الشبكة.

إطار عمل Netgame

تشترك جميع الألعاب المختلفة عبر الشبكة بشيءٍ واحد فيما يتعلّق بالشبكات على الأقل؛ حيث لا بُدّ من وجود طريقةٍ لإيصال الأفعال التي يفعلها لاعبٌ معينٌ إلى اللاعبين الآخرين عبر الشبكة. لذلك، سيكون من الأفضل لو أتحنا تلك الإمكانيات بقاعدةٍ مشتركة قابلةٍ لإعادة الاستخدام بواسطة الكثير من الألعاب المختلفة. على سبيل المثال، تحتوي حزمة netgame.common، التي طورها الكاتب على الكثير من الأصناف.

لم نتعامل كثيرًا مع الحزم packages بهذه السلسلة بخلاف استخدام الأصناف المبنية مسبقًا built-in. كنا قد شرحنا ماهية الحزم بمقال بيئات البرمجة programming environment في جافا، ولكننا لم نَستخدِم بجميع الأمثلة البرمجية حتى الآن سوى الحزمة الافتراضية default package. تُستخدَم الحزم عمليًا بجميع المشروعات البرمجية بهدف تقسيم الشيفرة إلى مجموعةٍ من الأصناف المرتبطة، ولذلك كان من البديهي تعريف إطار العمل القابل لإعادة الاستخدام بحزمةٍ يُمكِن إضافتها مثل مجموعة متكاملة unit إلى أي مشروع.

تُسهِّل بيئات التطوير المتكاملة Integrated development environments مثل Eclipse استخدام الحزم packages: فمن أجل استخدام حزمة netgame ضمن مشروع باِستخدَام إحدى بيئات التطوير المتكاملة، كل ما عليك فعله هو نَسْخ مجلد netgame بالكامل إلى المشروع؛ ونظرًا لاستخدام حزمة netgame مكتبة JavaFX، ينبغي ضبط المشروع ببيئة Eclipse ليدعم استخدام مكتبة JavaFX.

إذا كنت تَعمَل بسطر الأوامر، ينبغي أن يحتوي مجلد العمل working directory على المجلد netgame بهيئة مجلدٍ فرعي. إذا لم تكن تَستخدِم إصدارًا قديمًا من JDK يتضمَّن مكتبة JavaFX مسبقًا، فينبغى أن تُضيف خيار JavaFX إلى أوامر javac و java. لنفترض أننا عرَّفنا الأمرين jfxc و jfx ليكافئا الأمرين javac و java بعد إضافة خيارات مكتبة JavaFX إليهما. والآن، إذا أردنا أن نُصرِّف كل ملفات جافا المُعرَّفة بحزمة netgame.common مثلًا، يُمكِننا استخدام الأمر التالي في نظامي Mac OS و Linux:

jfxc netgame/common/*.java

أما بالنسبة لنظام Windows، يُمكِننا استخدام الشرطة المائلة للخلف بدلًا من الشرطة المائلة للأمام كما يلي:

jfxc netgame\common\*.java

بالنسبة لإصدارات JDK التي تتضمَّن بالفعل مكتبة JavaFX، يمكن استخدام javac وليس jfxc. ستحتاج إلى أوامرٍ مشابهة لتتمكَّن من تصريف الشيفرة المصدرية لبعض الأمثلة ضمن هذا المقال، وستجدها معرَّفةً ضمن حزمٍ فرعية subpackages أخرى من حزمة netgame.

لتتمكَّن من تشغيل البرنامج main المُعَّرف بحزمةٍ معينة، ينبغي أن تكون بمجلدٍ يتضمَّن تلك الحزمة بهيئة مجلدٍ فرعي، كما ينبغي استخدام الاسم الكامل للصنف الذي تريد تشغيله. إذا أردت مثلًا تشغيل الصنف ChatRoomWindow -سيُناقش لاحقًا ضمن هذا المقال- المُعرَّف بحزمة netgame.chat، شغِّل الأمر التالي:

jfx netgame.chat.ChatRoomWindow

تُعدّ التطبيقات التي سنناقشها ضمن هذا المقال أمثلةً على البرمجة الموزَّعة؛ فهي تتضمَّن عدة حواسيب تتواصل عبر الشبكة. تَستخدِم تلك التطبيقات كما هو الحال في مثال قسم الحوسبة الموزعة من مقال المقال السابق خادمًا server مركزيًا أو برنامجًا رئيسيًا master مُوصَّلًا بمجموعةٍ من العملاء clients؛ حيث تمر جميع الرسائل عبر الخادم، أي لا يستطيع عميلٌ معينٌ إرسال رسالةٍ إلى عميلٍ آخر مباشرةً. سنشير إلى الخادم ضمن هذا المقال باسم "الموزّع hub" بمعنى موزع الاتصالات.

001Hub_And_Clients.png

هناك عدة أشياء ينبغي أن تفهمها جيدًا: لا بُدّ أن يكون الموزع مُشغَّلًا قبل بدء تشغيل أيٍّ من العملاء. يتصل العملاء بالموزع ليتمكَّنوا من إرسال رسائلهم إليه، ويُعالِج الموزع جميع رسائل العملاء واحدةً تلو الأخرى بنفس ترتيب استقبالها، وبناءً على تلك المعالجة، يُمكِنه أن يرسل عدة رسائلٍ إلى عميلٍ واحدٍ أو أكثر. يَملُك كل عميل مُعرِّف هوية ID خاصٍ به، ويُمثِل ذلك إطار عمل framework عام يُمكِن اِستخدامه بمختلف أنواع التطبيقات، بحيث يُعرِّف كل تطبيق ما يخصُّه من رسائلٍ ومعالجات. لنتعمَّق الآن بتفاصيل البرنامج.

كانت الرسائل في قسم الحوسبة الموزعة من المقال السابق تُرسَل جيئةً وذهابًا بين الخادم والعميل وفقًا لمتتاليةٍ مُحدَّدةٍ ومُعرَّفةٍ مُسبقًا، حيث كان الاتصال بين الخادم والعميل بمثابة اتصالٍ بين خيطٍ واحدٍ مُشغَّلٍ ببرنامج الخادم وخيطٍ آخرٍ مُشغّلٍ ببرنامج العميل. بالنسبة لإطار عمل netgame، نريد أن نجعل الاتصال غير متزامن asynchronous؛ بمعنى أننا لا نريد انتظار وصول الرسائل بناءً على متتاليةٍ متوقَّعة مسبقًا، ولجعل هذا ممكنًا، يَستخدِم العميل وفقًا لإطار عمل netgame خيطين للاتصال: الأول لإرسال الرسائل إلى الموزع، والآخر لاستقبال الرسائل منه. وبالمثل، يَستخدِم الموزع وفقًا لإطار عمل netgame خيطين للاتصال مع كل عميل.

يكون الموزع عمومًا متصلًا مع عدة عملاء، ويستطيع استقبال الرسائل من أي عميل بأي وقت. ينبغي أن يُعالِج الموزع الرسائل بطريقةٍ ما، ويَستخدِم لإجراء تلك المعالجة خيط اتصالٍ واحد فقط لمعالجة جميع الرسائل. عندما يَستقبِل ذلك الخيط رسالةً معينةً من عميلٍ ما، فإنه يُدْخِلها إلى رتلٍ queue يحتوي على الرسائل المُستقبَلة؛ حيث يتوفَّر رتل واحد فقط تُخزِّن به رسائل جميع العملاء. في المقابل، يُنفِّذ خيط معالجة الرسائل حلقة تكرار loop يقرأ خلالها رسالةً واحدةً من الرتل ويُعالجها، ثم يقرأ رسالةً أخرى ويُعالجها وهكذا. لاحِظ أن الرتل هو كائن من النوع LinkedBlockingQueue.

002Hub_And_Client_Threads.png

يتضمَّن الموزع خيطًا آخرًا غير مُوضَّحٍ بالصورة السابقة؛ حيث يُنشِئ ذلك الخيط مقبسًا من النوع ServerSocket، ويَستخدِمه للاستماع لطلبات الاتصال الآتية من العملاء. يُسلّم الموزع كل طلب اتصالٍ يَستقبِله إلى كائنٍ آخر من النوع المُتداخِل ConnectionToClient؛ حيث يُعالِج ذلك الكائن الاتصال مع العميل. يَملُك كل عميلٍ متصلٍ رقم مُعرِّف هويةٍ خاصٍ به، حيث تُسنَد مُعرِّفات الهوية 1 و2 و3 .. إلى العملاء عند اتصالهم؛ ونظرًا لأن بإمكانهم غلق الاتصال، قد لا تكون أرقام مُعرِّفات الهوية الخاصة بالعملاء المتصلين متتاليةً ضمن لحظةٍ معينة. يُستخدَم متغيرٌ من النوع TreeMap<Integer,ConnectionToClient>‎ لربط أرقام مُعرِّفات الهوية الخاصة بالعملاء المتصلين مع الكائنات المسؤولة عن معالجة اتصالاتهم.

تمثِّل الرسائل المُرسَلة والمُستقبَلة كائنات، ولذلك اِستخدَمنا مجاري دْخَل وخَرْج I/O streams من النوع ObjectInputStream و ObjectOutputStream لقراءة الكائنات وكتابتها. (انظر قسم إدخال وإخراج الكائنات المسلسلة من مقال قنوات الدخل والخرج وعمليتي القراءة والكتابة في جافا). لقد غلَّفنا مجرى الخرج الخاص بالمقبس باستخدام الصنف ObjectOutputStream لنَسمَح بنقل الكائنات عبر المقبس؛ في حين غلَّفنا مجرى الدخل الخاص بالمقبس باستخدام الصنف ObjectInputStream لنَسمَح باستقبال الكائنات. ملاحظة: لا بُدّ أن تُنفِّذ الكائنات المُستخدَمة مع هذا النوع من المجاري الواجهة java.io.Serializable.

ستَجِد الصنف Hub مُعرَّفًا بالملف Hub.java في حزمة netgame.common. يجب تخصيص رقم المنفذ الذي سيستمع إليه مقبس الخادم بهيئة معامل يُمرَّر إلى الباني constructor. يُعرِّف الصنف Hub التابع التالي:

protected void messageReceived(int playerID, Object message)

عندما تصِل رسالةٌ من عميلٍ معينٍ إلى مقدمة رتل الرسائل، يقرأها خيط معالجة الرسائل من الرتل، ويَستدعِي ذلك التابع. بتلك اللحظة فقط، تبدأ المعالجة الفعلية لرسالة العميل.

يُمثِّل المعامل الأول playerID رقم مُعرِّف الهوية الخاص بالعميل المُرسِل للرسالة؛ بينما يُمثِّل المعامل الثاني الرسالة نفسها. يُمرِّر التابع نسخةً من تلك الرسالة إلى جميع العملاء المتصلين، ويُمثِّل ذلك المعالجة الافتراضية التي يُجريها الموزع على الرسائل المُستقبَلة؛ ولكي يُنفِّذ ذلك، يُغلِّف الموزع أولًا كُلًا من رقم مُعرِّف الهوية playerID ومحتويات الرسالة message بكائنٍ من النوع ForwardedMessage (مُعرَّفٌ بالملف ForwardedMessage.java بحزمة netgame.common).

قد تكون المعالجة الافتراضية مناسبة تمامًا لاحتياجات تطبيقٍ بسيطٍ، مثل برنامج غرفة المحادثة الذي سنناقشه لاحقًا، ولكن سنضطّر إلى تعريف صنفٍ فرعي subclass من الصنف Hub بالنسبة لغالبية التطبيقات، وإعادة تعريف التابع messageReceived()‎ به لإجراء معالجةٍ أكثر تعقيدًا. في الواقع، يحتوي الصنف Hub على عدة توابعٍ أخرى، والتي قد تحتاج إلى إعادة تعريفها أيضًا. نستعرِض بعضًا منها فيما يلي:

  • protected void playerConnected(int playerID)‎: يُستدعَى ذلك التابع في كل مرةٍ يتصل خلالها لاعبٌ بالموزع؛ حيث يُمثِّل المعامل playerID رقم مُعرِّف الهوية الخاص باللاعب الجديد. لا يفعَل هذا التابع شيئًا بالصنف Hub. (لقد أرسَلَ الموزع رسالةً من النوع StatusMessage بالفعل إلى كل عميل ليخبره بوجود لاعبٍ جديد؛ أما الغرض من التابع playerConnected()‎ فهو إجراء أي عملياتٍ أخرى إضافية قد ترغب الأصناف الفرعية المُشتقَة من الصنف Hub بتنفيذها). من الممكن الوصول إلى قائمة مُعرِّفات الهوية لجميع اللاعبين المتصلين حاليًا باستدعاء التابع getPlayerList.
  • protected void playerDisconnected(int playerID)‎: يُستدعَى ذلك التابع في كل مرةٍ يُغلِق خلالها لاعبٌ معينٌ اتصاله مع الموزع (بعد أن يُرسِل الموزع رسالةً من النوع StatusMessage إلى العملاء). يُمثِّل المعامل رقم مُعرِّف الهوية الخاص باللاعب الذي أغلق الاتصال. لا يفعَل هذا التابع شيئًا بالصنف Hub.

يُعرِّف الصنف Hub بعض التوابع العامة public المفيدة منها:

  • sendToAll(message)‎: يُرسِل ذلك التابع الرسالة message المُمرَّرة لكل عميلٍ متصل بالموزع حاليًا. لا بُدّ أن تكون الرسالة كائنًا غير فارغ يُنفِّذ الواجهة Serializable.
  • sendToOne(recipientID,message)‎: يُرسِل ذلك التابع الرسالة message المُمرَّرة إلى مُستخدِمٍ واحدٍ فقط؛ حيث يُمثِّل المعامل الأول recipientID رقم مُعرِّف الهوية الخاص بالعميل الذي ينبغي أن يَستقبِل تلك الرسالة، ويُعيد التابع قيمة من النوع boolean تُساوِي false في حالة عدم وجود عميلٍ مقابلٍ لقيمة recipientID.
  • shutDownServerSocket()‎: يُغلِق هذا التابع مقبس الخادم الخاص بالموزع؛ لكي لا يتمكَّن أي عميلٍ آخر من الاتصال. يُمكِننا اِستخدَامه بالألعاب الثنائية (لاعبين فقط) بعد أن يتصِل اللاعب الثاني مثلًا.
  • setAutoreset(autoreset)‎: يضبُط قيمة خاصية autoreset؛ فإذا كانت قيمتها تساوي true، يُعَاد ضبط المجاري -من النوع ObjectOutputStreams- المُستخدَمة لنقل الرسائل إلى العملاء أوتوماتيكيًا قبل نقل تلك الرسائل. القيمة الافتراضية لتلك الخاصية هي false. يكون لإعادة ضبط مجرًى من النوع ObjectOutputStream معنًى إذا كان هناك كائنٌ قد كُتِبَ بالمجرى بالفعل، ثم عُدّل، ثم كُتِبَ إلى المجرى مرةً أخرى. إذا لم نُعِد ضبط المجرى قبل كتابة الكائن المُعدَّل به، تُرسَل القيمة القديمة غير المُعدَّلة إلى المجرى بدلًا من القيمة الجديدة. في تلك الحالة، يُفضَّل اِستخدَام كائناتٍ ثابتة غير قابلة للتعديل immutable مع الاتصال، ولا تكون عندها إعادة الضبط ضرورية.

ينبغي أن تقرأ الشيفرة المصدرية للملف Hub.java للإطلاع على طريقة تنفيذ كل ما سبق وللمزيد من المعلومات في العموم. مع قليلٍ من الجهد والدراسة، ستكون قادرًا على فهم كل شيء موجود ضمن ذلك الملف، ومع ذلك تحتاج فقط إلى فهم الواجهة العامة public والمحمية protected بالصنف Hub والأصناف الأخرى المُعرَّفة بإطار عمل netgame حتى تتمكَّن من كتابة بعض التطبيقات المبنية عليها.

لننتقل الآن إلى جانب العميل، ستَجِد تعريف الصنف المُمثِل للعميل بالملف Client.java بحزمة netgame.common؛ حيث يحتوي الصنف على باني كائن constructor يَستقبِل كُلًا من اسم أو عنوان بروتوكول الإنترنت، ورقم المنفذ الخاصين بالموزع الذي سيتصل به العميل. يُسبِّب هذا الباني تعطيلًا إلى أن يُنشَأ الاتصال.

الصنف Client هو صنفٌ مجرَّد abstract؛ أي لا بُدّ لأي تطبيق netgame أن يُعرِّف صنفًا فرعيًا مُشتقًا من الصنف Client، وأن يُوفِّر تعريفًا للتابع المُجرّد abstract التالي:

abstract protected void messageReceived(Object message);

يُستدعَى التابع السابق بكل مرةٍ يَستقبِل خلالها العميل رسالةً من الموزع. قد يُعيد الصنف الفرعي المُشتق تعريف override التوابع المحمية الآتية:

  • playerConnected.
  • playerDisconnected.
  • serverShutdown.
  • connectionClosedByError.

انظر الشيفرة المصدرية لمزيدٍ من المعلومات. علاوةً على ذلك، يحتوي الصنف Client على متغير نسخة instance variable، اسمه connectedPlayerIDs من نوع المصفوفة int[]‎. تُمثِّل تلك المصفوفة قائمة أرقام مُعرِّفات الهوية الخاصة بجميع العملاء المتصلين حاليًا بالموزع. يُعرِّف الصنف Client مجموعةً من التوابع العامة، وسنستعرِض بعضًا من أهمها فيما يلي:

  • send(message)‎: ينقل هذا التابع رسالةً إلى الموزع. قد يكون المعامل message أي كائنٍ غير فارغ بشرط أن يُنفِّذ الواجهة Serializable.
  • getID()‎: يسترجع هذا التابع رقم مُعرِّف الهوية الذي أسنده الموزع إلى العميل.
  • disconnect()‎: يغلِق اتصال العميل مع الموزع. لاحظ أن العميل لن يتمكَّن من إرسال أي رسائلٍ أخرى بعد غلق الاتصال. إذا حاول العميل فعل ذلك، سيُبلِّغ التابع send()‎ عن استثناءٍ من النوع IllegalStateException.

صُممّ الصنفان Hub و Client عمومًا ليُوفِّرا إطار عملٍ عام يُمكِن استخدامه أساسًا لكثيرٍ من الألعاب الشبكية المختلفة والبرامج المُوزَّعة أيضًا. جميع التفاصيل منخفضة المستوى المتعلقة بالاتصالات الشبكية والخيوط المتعدّدة مخفيةٌ ضمن الأقسام الخاصة private المُعرَّفة بتلك الأصناف، وتتمكَّن بذلك التطبيقات المبنية على تلك الأصناف من العمل وفقًا لمصطلحاتٍ عالية المستوى مثل اللاعبين والرسائل.

صُممت تلك الأصناف على عدة مراحل بناءً على الخبرة المُكتسبَة من مجموعةٍ من التطبيقات الحقيقية، ولهذا يُفضَّل إلقاء نظرةٍ على الشيفرة المصدرية لرؤية طريقة استخدام الصنفين Hub و Client للخيوط والمقابس ومجاري الدْخَل والخرج. سنمرّ سريعًا بالجزء المُتبقِي من هذا المقال على ثلاثة تطبيقات مبنيةٍ على إطار عمل netgame وبدون مناقشة التفاصيل. يُمكِنك الإطلاع على الشيفرة المصدرية للتطبيقات الثلاثة كاملةً بحزمة netgame.

تطبيق غرفة محادثة بسيط

تطبيقنا الشبكي الأول هو "غرفة محادثة" تُمكِّن المُستخدمين من الاتصال بخادمٍ معين، ومن ثم إرسال الرسائل؛ حيث يستطيع المُستخدمون المتواجدون بنفس الغرفة رؤية تلك الرسائل. يتشابه هذا التطبيق مع البرنامج GUIChat من قسم برنامج محادثة عبر الشبكة غير متزامن من مقال الخيوط Threads والشبكات في جافا باستثناء أنه من الممكن لأي عددٍ من المُستخدِمين التواجد بالمحادثة. لا يُعدّ هذا التطبيق لعبة، ولكنه يظهر الوظائف الأساسية التي يُوفِّرها إطار عمل netgame.

يتكوَّن تطبيق غرفة المحادثة من برنامجين: الأول هو ChatRoomServer.java، وهو في الواقع برنامجٌ بسيطٌ للغاية، فكل ما يفعله هو إنشاء موزع Hub يَستمِع إلى طلبات الاتصال القادمة من عملاء netgame:

public static void main(String[] args) {
    try {
        new Hub(PORT);
    }
    catch (IOException e) {
        System.out.println("Can't create listening socket.  Shutting down.");
    }
}

يُعرَّف رقم المنفذ PORT على أنه ثابتٌ بالبرنامج، ويمكن أن يكون أي رقمٍ عشوائي شرط أن يَستخدِم الخادم والعملاء نفس الرقم. يَستخدِم البرنامج ChatRoom الصنف Hub نفسه لا صنفًا فرعيًا منه.

أما البرنامج الثاني من تطبيق غرفة المحادثة فهو البرنامج ChatRoomWindow.java، الذي ينبغي أن يُشغِّله المُستخدمون الذين يريدون المشاركة بغرفة المحادثة. يجب أن يعرِف المُستخدِم اسم أو عنوان بروتوكول الانترنت الخاص بالحاسوب الذي يَعمَل عليه الموزع. (لأغراض اختبار البرنامج، يُمكِنك تشغيل برنامج العميل بنفس الحاسوب الذي يَعمَل عليه الموزع باستخدام localhost اسمًا لحاسوب الموزع).

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

لابُدّ أن تُعرِّف تطبيقات netgame صنفًا فرعيًا مشتقًا من الصنف المجرّد Client. بالنسبة لتطبيق غرفة المحادثة، عرَّفنا العملاء بواسطة الصنف المتداخل ChatClient داخل البرنامج ChatRoomWindow. يُعرِّف البرنامج متغير النسخة connection من النوع ChatClient، والذي يُمثِّل اتصال البرنامج مع الموزع. عندما يُدْخِل المُستخدِم رسالة، تُرسَل الرسالة إلى الموزع باستدعاء التابع التالي:

connection.send(message);

عندما يَستقبِل الموزع رسالة، فإنه يُغلِّفها ضمن كائنٍ من النوع ForwardedMessage مع إضافة رقم مُعرِّف الهوية الخاص بالعميل المُرسِل للرسالة. بعد ذلك، يُرسِل الموزع نسخةً من هذا الكائن إلى كل عميلٍ متصلٍ بالموزع، بما في ذلك العميل الذي أرسل الرسالة. من الجهة الأخرى، عندما يَستقبِل العميل رسالةً من الموزع، يُستدعَى التابع messageReceived()‎ المُعرَّف بالكائن المنتمي إلى الصنف ChatClient؛ ويُعيد الصنف ChatClient تعريف ذلك التابع لكي يجعله يضيف الرسالة إلى المساحة النصية ببرنامج ChatClientWindow.

اختصارًا لما سبق، تُرسَل أي رسالةٍ يُدْخِلها أي مُستخدِم إلى الموزع بينما يُرسِل الموزع نُسخًا من أي رسالةٍ يَستقبِلها إلى كل عميل، ولذلك يتسلَّم جميع العملاء نفس مجرى الرسائل من الموزع. بالإضافة إلى ما سبق، يُنبَّه كل عميلٍ عندما يَتصِل لاعبٌ جديدٌ بالموزع أو عندما يُغلِق لاعبٌ معينٌ اتصاله مع الموزع، وكذلك عندما يَفقِد هو نفسه اتصاله مع الموزع. يعيد البرنامج ChatClient تعريف التوابع التي تُستدعَى عند وقوع تلك الأحداث ليتمكَّن من إضافة رسائلٍ مناسبة إلى المساحة النصية. نستعرِض فيما يلي تعريف صنف العميل الخاص بتطبيق غرفة المحادثة:

// 1
private class ChatClient extends Client {

    // 2
    ChatClient(String host) throws IOException {
        super(host, PORT);
    }

    //3
    protected void messageReceived(Object message) {
        if (message instanceof ForwardedMessage) {  
                  // لا يوجد أنواع رسائلٍ أخرى متوقعة
            ForwardedMessage bm = (ForwardedMessage)message;
            addToTranscript("#" + bm.senderID + " SAYS:  " + bm.message);
        }
    }

    // 4
    protected void connectionClosedByError(String message) {
        addToTranscript(
           "Sorry, communication has shut down due to an error:\n     " 
                                     + message );
        Platform.runLater( () -> {
            sendButton.setDisable(true);
            messageInput.setEditable(false);
            messageInput.setDisable(true);
            messageInput.setText("");
        });
        connected = false;
        connection = null;
    }

    // 5
    protected void playerConnected(int newPlayerID) {
        addToTranscript(
                "Someone new has joined the chat room, with ID number " 
                + newPlayerID );
    }

    // 6
    protected void playerDisconnected(int departingPlayerID) {
        addToTranscript( "The person with ID number " 
                            + departingPlayerID + " has left the chat room");
    }

} // end nested class ChatClient

حيث أن:

  • [1] يتصِل برنامج ChatClient مع الموزع ويُستخدَم لإرسال الرسائل واستقبالها من وإلى الموزع. تكون الرسائل المُستقبَلة من الموزع من النوع ForwardedMessage وتحتوي على رقم مُعرِّف الهوية الخاص بالمُرسِل وعلى السلسلة النصية التي أرسلها المُستخدِم.
  • [2] يُنشِئ اتصالًا مع خادم غرفة المحادثة بالحاسوب المُخصَّص.
  • [3] يُنفَّذ هذا التابع عند استقبال رسالةٍ من الخادم. ينبغي أن تكون الرسالة من النوع ForwardedMessage، وتُمثِّل شيئًا أرسله أحد المُستخدِمين المتواجدين بغرفة المحادثة. تُضَاف الرسالة ببساطة إلى الشاشة مع رقم مُعرِّف الهوية الخاص بالمُرسِل.
  • [4] يُستدعَى هذا التابع عندما يُغلَق الاتصال مع عميلٍ ما نتيجةً لحدوث خطأ ما (يحدث ذلك عند غلق الخادم).
  • [5] يُظهِر رسالةً على الشاشة عندما ينضم شخصٌ ما إلى غرفة المحادثة.
  • [6] يُظهر رسالةً على الشاشة عندما يغادر شخصٌ ما غرفة المحادثة.

يُمكِنك الإطلاع على ملفات الشيفرة المصدرية الخاصة بتطبيق غرفة المحادثة من حزمة netgame.chat.

اقتباس

ملاحظة: يُسنِد الموزع بتطبيق غرفة المحادثة رقم مُعرِّف هوية لكل مُستخدِم عند اتصاله، ولكن ما يزال المُستخدمون مجهولين، وقد لا يكون ذلك مناسبًا. ألقِ نظرةً على تمرين 12.7 بنهاية هذا المقال حيث ناقشنا طريقةً لمعالجة تلك المشكلة.

لعبة إكس-أو عبر الشبكة

تطبيقنا الثاني سيكون لعبةً بسيطةً للغاية: لعبة إكس-أو الشهيرة؛ حيث يضع لاعبان بتلك اللعبة علاماتٍ بلوحةٍ مكوَّنة من 3 صفوف و3 أعمدة. يلعب أحدهما الرمز X بينما يلعب الآخر O، ويكون الهدف هو الحصول على 3 رموز X أو 3 رموز O بصفٍ واحد.

تتكوَّن حالة لعبة إكس-أو بأي لحظة من مجموعةٍ من المعلومات، مثل مكونات اللوحة الحالية؛ واللاعب الذي حان دوره؛ وفي حال انتهت اللعبة، فمن الفائز ومن الخاسر. إذا لم نكن نُطوّر برنامجًا شبكيًا، كان بإمكاننا اِستخدَام متغيرات نسخة لتمثيل حالة اللعبة بحيث يستعين بها البرنامج لتحديد طريقة رسم اللوحة وطريقة الاستجابة لأفعال المُستخدِم مثل نقرات الفأرة، ولكن نظرًا لأننا نُطوِّر برنامجًا شبكيًا من اللعبة، سنَستخدِم ثلاثة كائنات؛ بحيث ينتمي اثنان منهما إلى صنف العميل الذي يُوفِّر واجهة المُستخدِمين للعبة بالإضافة إلى الكائن المُمثِل للموزع والمسؤول عن إدارة الاتصالات مع العملاء. لا تتواجد تلك الكائنات بنفس الحاسوب، وبالتالي لا يُمكِنهم بالتأكيد اِستخدَام نفس متغيرات الحالة؛ ومع ذلك هناك حالةٌ واحدةٌ مُحدَّدة للعبة بأي لحظة، وينبغي أن يكون اللاعبان على درايةٍ بتلك الحالة.

يُمكِننا حل تلك المشكلة بتخزين الحالة "الرسمية" للعبة بالموزع، وإرسال نسخةٍ منها إلى كل لاعب كلما تغيرت. لا يستطيع اللاعبان تعديل الحالة مباشرةً، فعندما يُنفِّذ لاعبٌ فِعلًا معينًا مثل وضع قطعةٍ على اللوحة، يُرسَل ذلك الفعِل إلى الموزع بهيئة رسالة. بعد ذلك، يُعدِّل الموزع حالة اللعبة لكي تَعكس نتيجة ذلك الفعل، ثم يُرسِل الحالة الجديدة لكلا اللاعبين، وعندها تُحدَّث نافذتهما لتعكس الحالة الجديدة. يُمكِننا بتلك الطريقة ضمَان ظهور اللعبة بنفس الحالة دائمًا عند كلا اللاعبين. بدلًا من إرسال نسخةٍ كاملةٍ من الحالة بكل مرةٍ تُعدَّل فيها، قد نُرسِل ذلك التعديل فقط. ولكن، سيتطلَّب ذلك استخدام طريقةٍ ما لتشفير التعديلات وتحويلها إلى رسائلٍ يُمكِن إرسالها عبر الشبكة. نظرًا لأن الحالة بهذا البرنامج بسيطةٌ للغاية، فإنه من الأسهل إرسالها كاملة.

ستَجِد البرنامج إكس-أو مُعرَّفًا بعدة أصناف بحزمة netgame.tictactoe؛ حيث يُمثِّل الصنف TicTacToeGameState حالة اللعبة، ويتضمَّن التابع التالي:

public void applyMessage(int senderID, Object message)

يُعدِّل ذلك التابع حالة اللعبة لتَعكِس تأثير الرسالة المُرسَلة من إحدى اللاعبين؛ حيث تُمثِّل تلك الرسالة فعِلًا معينًا أقدم عليه اللاعب، مثل النقر على اللوحة.

لا يَعرِف الصنف Hub أي شيءٍ عن تطبيق إكس-أو، ولأن الموزع بهذا التطبيق ينبغي أن يحتفظ بحالة اللعبة، كان من الضروري تعريف صنفٍ فرعي مشتقٍ من الصنف Hub. عرَّفنا الصنف TicTacToeGameHub، وهو صنفٌ بسيطٌ للغاية يُعيد تعريف التابع messageReceived()‎ ليجعله يَستجيب لرسائل اللاعبين بتطبيقها على حالة اللعبة ثم إرسال نسخةٍ من الحالة الجديدة لكلا اللاعبين. يُعيد ذلك الصنف تعريف التابعين playerConnected()‎ و playerDisconnected()‎ أيضًا لكي يُنفِّذا الفعِل المناسب؛ لأن المباراة تُلعَب فقط عندما يكون هناك لاعبين متصلين. انظر الشيفرة المصدرية كاملة:

package netgame.tictactoe;

import java.io.IOException;

import netgame.common.Hub;

// 1
public class TicTacToeGameHub extends Hub {

    private TicTacToeGameState state;  // يسجّل حالة اللعبة

    // 2
    public TicTacToeGameHub(int port) throws IOException {
        super(port);
        state = new TicTacToeGameState();
        setAutoreset(true);
    }

    // 3
    protected void messageReceived(int playerID, Object message) {
        state.applyMessage(playerID, message);
        sendToAll(state);
    }

    // 4
    protected void playerConnected(int playerID) {
        if (getPlayerList().length == 2) {
            shutdownServerSocket();
            state.startFirstGame();
            sendToAll(state);
        }
    }

    // 5
    protected void playerDisconnected(int playerID) {
        state.playerDisconnected = true;
        sendToAll(state);
    }
}

حيث أن:

  • [1]: يُمثِّل الموزع hub باللعبة إكس-أو. هناك موزعٌ واحدٌ فقط باللعبة، ويتصِل كلا اللاعبين بنفس الموزع، الذي تتواجد بد المعلومات الرسمية عن حالة اللعبة؛ وعند حدوث تغييرات بتلك الحالة، يُرسِل الموزع الحالة الجديدة لكلا اللاعبين ليتأكّد من ظهور نفس الحالة لكليهما.
  • [2]: يُنشِئ موزعًا يَستمِع إلى رقم المنفذ المُخصَّص. يَستدعِي التابع setAutoreset(true)‎ لكي يتأكّد من إعادة ضبط مجرى الخرج الخاص بكل عميل تلقائيًا قبل إرسال أي رسالة. يُعدّ ذلك ضروريًا لأن نفس الكائن المُمثِل للحالة يُرسَل مرارً وتكرارً مع بعض التعديلات. يُمثِّل port رقم المنفذ الذي سيستمِع إليه الموزع. قد يُبلِّغ التابع عن استثناءِ من النوع IOException في حالة عدم التمكُّن من فتح اتصالٍ برقم المنفذ المُخصَّص.
  • [3]: يُنفَّذ هذا التابع عند وصول رسالةٍ من عميل؛ حيث تُطبَّق الرسالة عندئذٍ على حالة اللعبة باستدعاء التابع state.applyMessage()‎. تُنقَل الحالة بعد ذلك إلى جميع اللاعبين المتصلين إذا كانت قد تغيرت.
  • [4]: يُستدعَى هذا التابع عند اتصال لاعب؛ وإذا كان ذلك اللاعب هو اللاعب الثاني، يُغلَق مقبس الاستماع الخاص بالخادم (يُسمح فقط وجود لاعبين). بعد ذلك، تبدأ اللعبة الأولى وتُنقَل الحالة الجديدة إلى كلا اللاعبين.
  • [5]: يُستدعَى هذا التابع عندما يغلق لاعبٌ اتصاله. يُنهِي ذلك اللعبة ويتسبَّب بإغلاقها عند اللاعب الآخر أيضًا. يحدث ذلك بضبط قيمة state.playerDisconnected إلى true ثم إرسال الحالة الجديدة إلى اللاعب الآخر إذا كان موجودًا لتبليغه بأن اللعبة قد انتهت.

يُمثَّل الصنف TicTacToeWindow واجهة اللاعب إلى اللعبة. كما حدث في تطبيق غرفة المحادثة، يُعرِّف ذلك الصنف صنفًا فرعيًا متداخلًا مُشتقًا من الصنف Client لتمثيل اتصال العميل مع الموزع. عندما تتغيّر حالة اللعبة، تُرسَل رسالةٌ إلى كل عميل، ويُستدعَى التابع messageReceived()‎ الخاص بالعميل ليُعالِج تلك الرسالة. يستدعي ذلك التابع بدوره التابع newState()‎ المُعرَّف بالصنف TicTacToeWindow لتحديث النافذة. اِستخدَمنا Platform.runLater()‎ لكي نَستدعيه بخيط تطبيق JavaFX :

protected void messageReceived(Object message) {
    if (message instanceof TicTacToeGameState) {
        Platform.runLater( () -> newState( (TicTacToeGameState)message ) );
    }
}

والآن، لنُشغِّل تطبيق إكس-أو، ينبغي أن يُشغِّل اللاعبان البرنامج Main.java الموجود بحزمة netgame.tictactoe، حيث يعرِض البرنامج نافذةً للمُستخدِم تُمكِّنه من اختيار بدء لعبة جديدة أو الانضمام إلى لعبةٍ موجودة.

إذا بدأ المُستخدِم لعبةً جديدة، يُنشِئ البرنامج موزعًا من النوع TicTacToeHub لإدارة اللعبة، ويَعرِض نافذةً جديدةً من النوع TicTacToeWindow، وتكون متصلةً بالموزع على الفور. تبدأ اللعبة بمجرد اتصال لاعبٍ آخر بذلك الموزع. في المقابل، إذا اختار المُستخدِم الانضمام إلى لعبةٍ موجودة، لا يُنشِئ البرنامج موزعًا جديدًا، وإنما يُنشِئ نافذةً من النوع TicTacToeWindow، وتحاول تلك النافذة الاتصال بالموزع الذي أنشأه اللاعب الأول. ولذلك، لا بُدّ أن يَعرِف اللاعب الثاني اسم الحاسوب الذي يَعمَل عليه برنامج اللاعب الأول. إذا أردت اختبار البرنامج، يُمكِنك تشغيل كل شيء بنفس الحاسوب واستخدام "localhost" اسمًا للحاسوب.

يُعدّ هذا البرنامج هو أول برنامج يَستخدِم نافذتين مختلفتين نراه. لاحِظ أن الصنف TicTacToeWindow مُعرَّف على أنه صنفٌ فرعي من الصنف Stage الذي يُستخدَم لتمثيل النوافذ بمكتبة JavaFX. تبدأ برامج JavaFX "بمرحلة رئيسية primary stage" يُنشئِها النظام ويُمرِّرها معاملًا إلى التابع start()‎، ولكن يستطيع التطبيق إلى جانب ذلك إنشاء أي نوافذ إضافية.

لعبة بوكر Poker عبر الشبكة

ننتقل الآن إلى التطبيق الذي يُعدّ المُلهم لإطار عمل netgame، وهو تطبيق لعبة بوكر. بالتحديد، نفَّذنا نسخةً من النسخة التقليدية "سحب خمسة بطاقات five card draw" من تلك اللعبة وبلاعبين. هذا التطبيق معقدٌ نوعًا ما، ولن نناقشه تفصيليًا هنا، ولكننا سنوضِّح تصميمه العام. يُمكِنك الإطلاع على الشيفرة المصدرية كاملة بحزمة netgame.fivecarddraw. ينبغي أن تكون على درايةٍ بتلك النسخة من اللعبة لتتمكَّن من فهم البرنامج بالكامل.

تتشابه لعبة Poker مع لعبة إكس-أو في العموم، حيث يتوفَّر الصنف Main الذي يُشغِّله كلا اللاعبين. يبدأ اللاعب الأول لعبةً جديدة بينما ينضم اللاعب الثاني إلى اللعبة الموجودة. يُمثِّل الصنف PokerGameState حالة اللعبة؛ بينما يدير الصنف الفرعي PokerHub اللعبة.

لعبة Poker أكثر تعقيدًا من لعبة إكس-أو، وهو ما ينعكس على حالة اللعبة، فهي معقدةٌ بالموازنة مع حالة لعبة إكس-أو، وبالتالي فإننا لا نرغب بنشر النسخة الجديدة من حالة اللعبة كاملةً إلى اللاعبين في كل مرة نُجرِي فيها تعديلًا صغيرًا على الحالة. علاوة على ذلك، لا معنى لأن يَعرِف اللاعبان حالة اللعبة بالكامل بما في ذلك بطاقات الخصم وبطاقات اللعب التي يَسحَب منها اللاعبان. لن تكون برامج العملاء مضطّرةً لعرض حالة اللعبة بالكامل للاعبين، ولكنهما قد يَستبدلا تلك البرامج ببرامجٍ أخرى ويتمكَّنا من الغش بسهولة. ولذلك، سيكون الموزع من النوع PokerHub فقط على درايةٍ بكامل حالة اللعبة بهذا التطبيق.

سيُمثِّل كائنٌ من الصنف PokerGameState حالة اللعبة من وجهة نظر لاعبٍ واحدٍ فقط. عندما تتغير حالة اللعبة، سيُنشِئ الموزع كائنين مختلفين من النوع PokerGameState يُمثِّلان حالة اللعبة من وجهة نظر كل لاعب، ثم سيُرسِل الكائن المناسب لكل لاعب. يُمكِنك الإطلاع على الشيفرة المصدرية لمزيدٍ من التفاصيل.

تُعدّ موازنة يد لاعبين لمعرفة أيهما أكبر الجزء الأصعب بلعبة البوكر، وقد عالجناها بهذا التطبيق بالصنف PokerRank. ربما تجده مفيدًا بألعاب بوكر أخرى.

ترجمة -بتصرّف- للقسم Section 5: Network Programming Example: A Networked Game Framework من فصل Chapter 12: Threads and Multiprocessing من كتاب Introduction to Programming Using Java.

اقرأ أيضًا


تفاعل الأعضاء

أفضل التعليقات

لا توجد أية تعليقات بعد



انضم إلى النقاش

يمكنك أن تنشر الآن وتسجل لاحقًا. إذا كان لديك حساب، فسجل الدخول الآن لتنشر باسم حسابك.

زائر
أضف تعليق

×   لقد أضفت محتوى بخط أو تنسيق مختلف.   Restore formatting

  Only 75 emoji are allowed.

×   Your link has been automatically embedded.   Display as a link instead

×   جرى استعادة المحتوى السابق..   امسح المحرر

×   You cannot paste images directly. Upload or insert images from URL.


×
×
  • أضف...