كيفية بناء لعبة واقع افتراضي متعددة اللاعبين في الوقت الفعلي (الجزء الثاني)

نشرت: 2022-03-10
ملخص سريع ↬ في هذا البرنامج التعليمي ، ستكتب ميكانيكا اللعبة للعبة الواقع الافتراضي ، والتي تقترن بشكل وثيق مع عناصر اللعبة متعددة اللاعبين في الوقت الفعلي.

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

وصف اللعبة هنا مقتطف من الجزء الأول من السلسلة: يتم إعطاء كل زوج من اللاعبين حلقة من الأجرام السماوية. الهدف هو "تشغيل" كل الأجرام السماوية ، حيث يكون الجرم السماوي "قيد التشغيل" إذا كان مرتفعًا وساطعًا. الجرم السماوي "متوقف" إذا كان منخفضًا وخافتًا. ومع ذلك ، فإن بعض الأجرام السماوية "المهيمنة" تؤثر على جيرانها: إذا غيرت الحالة ، فإن جيرانها يغيرون الحالة أيضًا. يمكن للاعب 2 التحكم في الأجرام السماوية ذات الأرقام الزوجية ، ويمكن للاعب 1 التحكم في الأجرام السماوية ذات الأرقام الفردية. هذا يجبر كلا اللاعبين على التعاون لحل اللغز.

تم تجميع الخطوات الثماني في هذا البرنامج التعليمي في 3 أقسام:

  1. تعبئة واجهة المستخدم (الخطوتان 1 و 2)
  2. إضافة ميكانيكا اللعبة (الخطوات من 3 إلى 5)
  3. إعداد الاتصال (الخطوات من 6 إلى 8)

سيختتم هذا الجزء بعرض توضيحي يعمل بكامل طاقته عبر الإنترنت ، ويمكن لأي شخص أن يلعبه. ستستخدم A-Frame VR والعديد من امتدادات A-Frame.

يمكنك العثور على الكود المصدري النهائي هنا.

اللعبة النهائية متعددة اللاعبين ، والتي تمت مزامنتها عبر عملاء متعددين
اللعبة النهائية متعددة اللاعبين ، والتي تمت مزامنتها عبر عملاء متعددين. (معاينة كبيرة)

1. إضافة المؤشرات المرئية

للبدء ، سنضيف مؤشرات مرئية لمعرف الجرم السماوي. قم بإدراج عنصر VR جديد a-text كأول عنصر فرعي في #container-orb0 ، في L36.

 <a-entity ...> <a-text class="orb-id" opacity="0.25" rotation="0 -90 0" value="4" color="#FFF" scale="3 3 3" position="0 -2 -0.25" material="side:double"></a-text> ... <a-entity position...> ... </a-entity> </a-entity>

"تبعيات" الجرم السماوي هي الأجرام السماوية التي سيتم تبديلها ، عند التبديل: على سبيل المثال ، لنفترض أن الجرم السماوي 1 له تبعيات الأجرام السماوية 2 و 3. وهذا يعني أنه إذا تم تبديل الجرم السماوي 1 ، فسيتم تبديل الأجرام السماوية 2 و 3 أيضًا. سنضيف المؤشرات المرئية .animation-position .

 <a-animation class="animation-position" ... /> <a-text class="dep-right" opacity="0.25" rotation="0 -90 0" value="4" color="#FFF" scale="10 10 10" position="0 0 1" material="side:double" ></a-text> <a-text class="dep-left" opacity="0.25"rotation="0 -90 0" value="1" color="#FFF" scale="10 10 10" position="0 0 -3" material="side:double" ></a-text>

تحقق من أن شفرتك تطابق شفرة المصدر الخاصة بنا للخطوة 1. يجب أن يتطابق الجرم السماوي الآن مع ما يلي:

الجرم السماوي مع المؤشرات المرئية لمعرف الجرم السماوي ومعرفات الأجرام السماوية التي ستطلقها
الجرم السماوي مع المؤشرات المرئية لمعرف الجرم السماوي ومعرفات الأجرام السماوية التي ستطلقها (معاينة كبيرة)

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

2. إضافة الأجرام السماوية ديناميكيا

في هذه الخطوة ، سنضيف الأجرام السماوية وفقًا لمواصفات JSON-esque للمستوى. هذا يسمح لنا بسهولة تحديد وإنشاء مستويات جديدة. سنستخدم الجرم السماوي من الخطوة الأخيرة في الجزء 1 كقالب.

للبدء ، قم باستيراد jQuery ، لأن هذا سيجعل تعديلات DOM ، وبالتالي التعديلات على مشهد VR ، أسهل. مباشرة بعد استيراد الإطار A ، أضف ما يلي إلى L8:

 <script src="https://code.jquery.com/jquery-3.3.1.min.js"></script>

حدد مستوى باستخدام مصفوفة. ستحتوي المصفوفة على قيم حرفية للكائن تقوم بترميز "تبعيات" كل كرة. داخل علامة <head> ، أضف تكوين المستوى التالي:

 <script> var orbs = [ {left: 1, right: 4}, {}, {on: true}, {}, {on: true} ]; </script>

في الوقت الحالي ، يمكن أن يكون لكل كرة تبعية واحدة فقط على "يمينها" وواحدة على "يسارها". مباشرة بعد إعلان orbs أعلاه ، أضف معالجًا يعمل عند تحميل الصفحة. هذا المعالج سوف (1) يكرر قالب الجرم السماوي و (2) يزيل الجرم السماوي للقالب ، باستخدام تكوين المستوى المقدم:

 $(document).ready(function() { function populateTemplate(orb, template, i, total) {} function remove(selector) {} for (var i=0; i < orbs.length; i++) { var orb = orbs[i]; var template = $('#template').clone(); template = populateTemplate(orb, template, i, orbs.length); $('#carousel').append(template); } remove('#template'); } function clickOrb(i) {}

بعد ذلك ، قم بملء وظيفة remove ، والتي تزيل ببساطة عنصرًا من مشهد الواقع الافتراضي ، بالنظر إلى المحدد. لحسن الحظ ، يلاحظ A-Frame التغييرات في DOM ، وبالتالي ، فإن إزالة العنصر من DOM يكفي لإزالته من مشهد VR. قم بتعبئة وظيفة remove على النحو التالي.

 function remove(selector) { var el = document.querySelector(selector); el.parentNode.removeChild(el); }

قم بتعبئة وظيفة clickOrb ، والتي تؤدي ببساطة إلى تشغيل إجراء النقر على الجرم السماوي.

 function clickOrb(i) { document.querySelector("#container-orb" + i).click(); }

بعد ذلك ، ابدأ في كتابة دالة populateTemplate . في هذه الوظيفة ، ابدأ بالحصول على .container . تحتوي حاوية الجرم السماوي أيضًا على المؤشرات المرئية التي أضفناها في الخطوة السابقة. علاوة على ذلك ، سنحتاج إلى تعديل سلوك النقرة في الجرم السماوي ، بناءً على onclick . في حالة وجود تبعية على اليسار ، قم بتعديل كل من المؤشر المرئي وسلوك onclick ليعكس ذلك ؛ الشيء نفسه ينطبق على التبعية اليمنى:

 function populateTemplate(orb, template, i, total) { var container = template.find('.container'); var onclick = 'document.querySelector("#light-orb' + i + '").emit("switch");'; if (orb.left || orb.right) { if (orb.left) { onclick += 'clickOrb(' + orb.left + ');'; container.find('.dep-left').attr('value', orb.left); } if (orb.right) { onclick += 'clickOrb(' + orb.right + ');'; container.find('.dep-right').attr('value', orb.right); } } else { container.find('.dep-left').remove(); container.find('.dep-right').remove(); } }

لا يزال في وظيفة populateTemplate ، قم بتعيين معرف الجرم السماوي بشكل صحيح في جميع الجرم السماوي وعناصر الحاوية الخاصة به.

 container.find('.orb-id').attr('value', i); container.attr('id', 'container-orb' + i); template.find('.orb').attr('id', 'orb' + i); template.find('.light-orb').attr('id', 'light-orb' + i); template.find('.clickable').attr('data-id', i);

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

 container.attr('onclick', onclick); container.find('lp-sphere').attr('seed', i); template.attr('rotation', '0 ' + (360 / total * i) + ' 0');

في ختام الوظيفة ، قم بإرجاع template بكل التكوينات أعلاه.

 return template;

داخل معالج تحميل المستند وبعد إزالة القالب remove('#template') ، قم بتشغيل الأجرام السماوية التي تم تكوينها ليتم تشغيلها في البداية.

 $(document).ready(function() { ... setTimeout(function() { for (var i=0; i < orbs.length; i++) { var orb = orbs[i]; if (orb.on) { document.querySelector("#container-orb" + i).click(); } } }, 1000); });

هذا يخلص إلى تعديلات جافا سكريبت. بعد ذلك ، سنقوم بتغيير الإعدادات الافتراضية للقالب إلى إعدادات الجرم السماوي "إيقاف التشغيل". غيّر موضع ومقياس #container-orb0 إلى ما يلي:

 position="8 0.5 0" scale="0.5 0.5 0.5"

ثم ، قم بتغيير شدة #light-orb0 إلى 0.

 intensity="0"

تحقق من أن كود المصدر الخاص بك يطابق شفرة المصدر الخاصة بنا للخطوة 2.

يجب أن يحتوي مشهد الواقع الافتراضي الآن على 5 أجرام سماوية ، مأهولة ديناميكيًا. علاوة على ذلك ، يجب أن يحتوي أحد الأجرام السماوية على مؤشرات مرئية للاعتماد ، كما هو موضح أدناه:

يتم ملء جميع الأجرام السماوية ديناميكيًا ، باستخدام قالب الجرم السماوي
يتم ملء جميع الأجرام السماوية ديناميكيًا ، باستخدام قالب الجرم السماوي (معاينة كبيرة)

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

3. أضف الحالة الطرفية

في هذه الخطوة ، سنضيف حالة طرفية. إذا تم تشغيل جميع الأجرام السماوية بنجاح ، سيرى اللاعب صفحة "النصر". للقيام بذلك ، سوف تحتاج إلى تتبع حالة جميع الأجرام السماوية. في كل مرة يتم فيها تشغيل الجرم السماوي أو إيقاف تشغيله ، سنحتاج إلى تحديث حالتنا الداخلية. لنفترض أن وظيفة toggleOrb إلى حالة التحديثات بالنسبة لنا. قم باستدعاء دالة toggleOrb في كل مرة تغير حالة الجرم السماوي: (1) أضف مستمع نقرات إلى معالج onload و (2) أضف toggleOrb(i); استدعاء clickOrb أخيرًا ، (3) حدد toggleOrb فارغًا.

 $(document).ready(function() { ... $('.orb').on('click', function() { var id = $(this).attr('data-id') toggleOrb(id); }); }); function toggleOrb(i) {} function clickOrb(i) { ... toggleOrb(i); }

للتبسيط ، سنستخدم تكوين المستوى الخاص بنا للإشارة إلى حالة اللعبة. استخدم toggleOrb لتبديل حالة on للجرم السماوي ith. يمكن أن يؤدي toggleOrb أيضًا إلى تشغيل حالة طرفية إذا تم تشغيل جميع الأجرام السماوية.

 function toggleOrb(i) { orbs[i].on = !orbs[i].on; if (orbs.every(orb => orb.on)) console.log('Victory!'); }

تحقق جيدًا من تطابق شفرتك مع شفرة المصدر الخاصة بنا للخطوة 3.

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

4. إنشاء كائن لاعب

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

في الوقت الحالي ، سيكون هذا ببساطة متغيرًا عالميًا. مباشرة بعد تحديد orbs ، حدد معرف اللاعب:

 var orbs = ... var current_player_id = 1;

تحقق جيدًا من أن الكود الخاص بك يطابق شفرة المصدر الخاصة بنا للخطوة 4. في الخطوة التالية ، سيتم استخدام معرف اللاعب هذا لتحديد الأجرام السماوية التي يمكن للاعب التحكم فيها.

5. تبديل الأجرام السماوية مشروطًا

في هذه الخطوة ، سنقوم بتعديل سلوك تبديل الجرم السماوي. على وجه التحديد ، يمكن للاعب 1 التحكم في الأجرام السماوية ذات الأرقام الفردية ويمكن للاعب 2 التحكم في الأجرام السماوية ذات الأرقام الزوجية. أولاً ، قم بتنفيذ هذا المنطق في كلا المكانين اللذين تتغير فيهما الأجرام السماوية:

 $('.orb').on('click', function() { var id = ... if (!allowedToToggle(id)) return false; ... } ... function clickOrb(i) { if (!allowedToToggle(id)) return; ... }

ثانيًا ، حدد وظيفة allowedToToggle مباشرة بعد clickOrb . إذا كان اللاعب الحالي هو اللاعب 1 ، فستُرجع المعرفات ذات الأرقام الفردية قيمة صدق ، وبالتالي ، سيسمح للاعب 1 بالتحكم في الأجرام السماوية ذات الأرقام الفردية. العكس صحيح بالنسبة للاعب 2. لا يُسمح لجميع اللاعبين الآخرين بالتحكم في الأجرام السماوية.

 function allowedToToggle(id) { if (current_player_id == 1) { return id % 2; } else if (current_player_id == 2) { return !(id % 2); } return false; }

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

في القسم التالي ، سنعمل على تسهيل الاتصال بين كلا اللاعبين عبر الخادم.

6. إعداد الخادم مع WebSocket

في هذه الخطوة ، ستقوم بإعداد خادم بسيط (1) لتتبع معرفات اللاعب و (2) رسائل الترحيل. ستتضمن هذه الرسائل حالة اللعبة ، بحيث يمكن للاعبين التأكد من أن كل منهم يرى ما يراه الآخر.

سنشير إلى index.html السابق الخاص بك باعتباره شفرة المصدر من جانب العميل. سوف نشير إلى الكود في هذه الخطوة على أنه كود المصدر من جانب الخادم. انتقل إلى glitch.com ، وانقر فوق "مشروع جديد" في الجزء العلوي الأيمن ، وفي القائمة المنسدلة ، انقر فوق "hello-express".

من اللوحة اليمنى ، حدد "package.json" ، وأضف socket-io إلى dependencies . يجب أن يتطابق قاموس dependencies الآن مع ما يلي.

 "dependencies": { "express": "^4.16.4", "socketio": "^1.0.0" },

من اللوحة اليمنى ، حدد "index.js ،" واستبدل محتويات هذا الملف بالحد الأدنى التالي من socket.io Hello World:

 const express = require("express"); const app = express(); var http = require('http').Server(app); var io = require('socket.io')(http); /** * Run application on port 3000 */ var port = process.env.PORT || 3000; http.listen(port, function(){ console.log('listening on *:', port); });

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

 /** * Maintain player IDs */ var playerIds = []; var smallestPlayerId = 1;

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

 function getPlayerId() { var playerId = smallestPlayerId; playerIds.push(playerId); while (playerIds.includes(smallestPlayerId)) { smallestPlayerId++; } return playerId; }

حدد وظيفة removePlayer ، والتي تقوم بتحديث smallestPlayerId وفقًا لذلك وتحرر playerId المقدم بحيث يمكن للاعب آخر أخذ هذا المعرف.

 function removePlayer(playerId) { if (playerId < smallestPlayerId) { smallestPlayerId = playerId; } var index = playerIds.indexOf(playerId); playerIds.splice(index, 1); }

أخيرًا ، حدد زوجًا من معالجات أحداث المقبس التي تسجل لاعبين جدد وتلغي تسجيل اللاعبين غير المتصلين ، باستخدام زوج من الأساليب المذكورة أعلاه.

 /** * Handle socket interactions */ io.on('connection', function(socket) { socket.on('newPlayer', function() { socket.playerId = getPlayerId(); console.log("new player: ", socket.playerId); socket.emit('playerId', socket.playerId); }); socket.on('disconnect', function() { if (socket.playerId === undefined) return; console.log("disconnected player: ", socket.playerId); removePlayer(socket.playerId); }); });

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

في الخطوة التالية ، سنقوم بتعديل العميل لاستلام واستخدام معرف اللاعب المرسل من الخادم.

7. تطبيق معرف اللاعب

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

استيراد socket.io في head عند L7:

 <script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/2.1.1/socket.io.js"></script>

بعد معالج تحميل المستند ، قم بإنشاء مثيل للمقبس وقم بإصدار حدث newPlayer . استجابةً لذلك ، سيقوم جانب الخادم بإنشاء معرف لاعب جديد باستخدام حدث playerId . أدناه ، استخدم عنوان URL لمعاينة مشروع Glitch بدلاً من lightful.glitch.me . يمكنك استخدام عنوان URL التجريبي أدناه ، ولكن لن تنعكس بالطبع أي تغييرات في التعليمات البرمجية تجريها.

 $(document).ready(function() { ... }); socket = io("https://lightful.glitch.me"); socket.emit('newPlayer'); socket.on('playerId', function(player_id) { current_player_id = player_id; console.log(" * You are now player", current_player_id); });

تحقق من أن الكود الخاص بك يطابق شفرة المصدر الخاصة بنا للخطوة 7. الآن ، يمكنك تحميل لعبتك على مستعرضين أو علامتي تبويب مختلفتين للعب جانبين من لعبة متعددة اللاعبين. سيتمكن اللاعب 1 من التحكم في الأجرام السماوية ذات الأرقام الفردية وسيتمكن اللاعب 2 من التحكم في الأجرام السماوية ذات الأرقام الزوجية.

ومع ذلك ، لاحظ أن تبديل الأجرام السماوية للاعب 1 لن يؤثر على حالة الجرم السماوي للاعب 2. بعد ذلك ، نحتاج إلى مزامنة حالات اللعبة.

8. مزامنة حالة اللعبة

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

قبل كل من استدعاءات toggleOrb ، أضف استدعاء socket.emit التالي.

 $(document).ready(function() { ... $('.orb').on('click', function() { ... socket.emit('toggleOrb', id); toggleOrb(id); }); }); ... function clickOrb(i) { ... socket.emit('toggleOrb', i); toggleOrb(i); }

بعد ذلك ، استمع إلى تبديل الجرم السماوي ، وقم بتبديل الجرم السماوي المقابل. مباشرة أسفل مستمع حدث playerId socket ، أضف مستمع آخر للحدث toggleOrb .

 socket.on('toggleOrb', function(i) { document.querySelector("#container-orb" + i).click(); toggleOrb(i); });

يؤدي هذا إلى اختتام التعديلات التي تم إجراؤها على التعليمات البرمجية من جانب العميل. تحقق جيدًا من تطابق شفرتك مع شفرة المصدر الخاصة بنا للخطوة 8.

يحتاج جانب الخادم الآن إلى تلقي وبث معرف الجرم السماوي. في index.js من جانب الخادم ، أضف المستمع التالي. يجب وضع هذا المستمع مباشرة أسفل مستمع disconnect المقبس.

 socket.on('toggleOrb', function(i) { socket.broadcast.emit('toggleOrb', i); });

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

اللعبة النهائية متعددة اللاعبين ، والتي تمت مزامنتها عبر عملاء متعددين
اللعبة النهائية متعددة اللاعبين ، والتي تمت مزامنتها عبر عملاء متعددين. (معاينة كبيرة)

خاتمة

بهذا نختتم برنامجنا التعليمي حول إنشاء لعبة واقع افتراضي متعددة اللاعبين. في هذه العملية ، لقد تطرقت إلى عدد من الموضوعات ، بما في ذلك النمذجة ثلاثية الأبعاد في A-Frame VR وتجارب متعددة اللاعبين في الوقت الفعلي باستخدام WebSockets.

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

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

يمكنك العثور على الكود المصدري النهائي هنا.

المزيد بعد القفز! أكمل القراءة أدناه ↓