ياسر جفال
الأعضاء-
المساهمات
80 -
تاريخ الانضمام
-
تاريخ آخر زيارة
-
عدد الأيام التي تصدر بها
5
نوع المحتوى
ريادة الأعمال
البرمجة
التصميم
DevOps
التسويق والمبيعات
العمل الحر
البرامج والتطبيقات
آخر التحديثات
قصص نجاح
أسئلة وأجوبة
كتب
دورات
كل منشورات العضو ياسر جفال
-
مدخل إلى unity3d إعداد المشهد والكاميرا في Unity3D
ياسر جفال علق على مقال ياسر جفال في صناعة الألعاب
ليس المهم القياس المهم أن تعمل اللعبة على أكثر الوضعيات انتشارا وهي نسبة 16:9 و 16:10 والتي تدعمها معظم شاشات الهواتف وأجهزة التابلت قياس 7 إنش. إن كنت تريد تشغيل اللعبة على قياسات شاشات أكبر عليك تجربتها للتأكد من أنها تتناسب وحجمها (مثلا تابلت 8 أو 9 إنش) -
مدخل إلى unity3d إعداد المشهد والكاميرا في Unity3D
ياسر جفال علق على مقال ياسر جفال في صناعة الألعاب
وعليكم السلام. شاشة الخصائص يمكن إظهارها من القائمة Window أما في حال عدم ظهورها قد تكون مفصولة عن الواجهة بطريق الخطأ. يمكن استعادة الشكل الافتراضي للمحرك باختيار إحدى Layouts من أعلى يمين الشاشة. الأشكال الفيزيائية يمكن تحميلها مجانا من الموقع kenney.nl -
مدخل إلى unity3d إعداد المشهد والكاميرا في Unity3D
ياسر جفال علق على مقال ياسر جفال في صناعة الألعاب
العفو -
وعليكم السلام ورحمة الله/ نعم هذا الأمر ممكن وهو أحد ميزات محرك يونيتي. في سلسلة الدروس هذه تم شرح التصدير للحواسيب وأجهزة أندرويد وكيفية بناء نظام إدخال يعمل على كلا النظامين دون الحاجة لتكرار كتابة الأكواد. أتمنى منك قراءة باقي الدروس لترى كيفية تطبيق هذا الأمر عمليا
-
ملاحظة: يمكن تحميل الملفات المصدرية لكامل هذه السلسلة عبر حساب أكاديمية حسوب على Github، يمكن أيضا تحميل ملف APK لتجريب اللعبة على أجهزة Android. استقبال مدخلات شاشة اللمساستقبال المدخلات من شاشة اللمس تختلف عن استقبال مدخلات الفأرة أو لوحة المفاتيح. فأنت هنا تتعامل مع شاشة تستقبل عدة لمسات، وتتعامل مع أحداث مثل وضع الإصبع وتحريكه ورفعه، أو وضع أصبعين وتقريبهما أو إبعادهما عن بعضهما. كل هذه الأنماط من الإدخال سيكون عليك التعرف عليها وتنفيذ ما يناسبها من أوامر. لنبدأ أولا مع مدخلات عناصر واجهة المستخدم مثل الأزرار. ما الذي يجب علينا فعله لاستقبال المدخلات على هذه العناصر؟ لا شيء! فهذا الأمر يقوم به محرك Unity تلقائيا. أي أنك عندما تلمس زرا على الشاشة سيتعرف التطبيق على هذا الأمر كأنك ضغطت على الزر وينفذ الأمر أو الأوامر المرتبطة به. لننتقل الآن للمدخلات الخاصة باللعبة. تذكر أننا قمنا بكتابة 3 بريمجات لاستقبال مدخلات الفأرة، وسنحتاج الآن لكتابة 3 مقابلها لاستقبال مدخلات شاشة اللمس. هذه البريمجات الثلاث هي: CameraMouseInput والذي أضفناه لقوالب عناصر الخلفية والأرضية من أجل تحريك الكاميرا والتحكم بتقريبها وإبعادها.LauncherMouseInput والذي أضفناه على قالب المقلاع بحيث يستقبل مدخلات اللاعب على المقذوفات ويحدد اتجاه التصويب وشدته ويطلق المقذوف حين إفلاته.SpecialAttackMouseInput والذي أضفناه على قوالب المقذوفات من أجل تنفيذ الهجوم الخاص عند الضغط على زر الفأرة بعد الإطلاق.الموضوع ببساطة شديدة أننا مقابل كل بريمج إدخال من الفأرة سنكتب بريمج إدخال من شاشة اللمس ونضيفه على نفس القالب. القاسم المشترك بين قوالب استقبال مدخلات اللمس أنها ستقوم بالتحقق من بيئة تشغيل اللعبة، فإذا وجدت أن نظام التشغيل هو Android ستقوم بتدمير بريمجات استقبال مدخلات الفأرة الموجودة معها على نفس الكائن. أهمية هذه الخطوة تكمن في أن Unity يحاول محاكاة وجود فأرة على شاشات اللمس عن طريق تحويل اللمسات إلى مدخلات فأرة. هذا الأمر يؤدي إلى سلوك غير مرغوب لا يفيد في لعبتنا، لذا نتجنبه عن طريق حذف أي بريمج يقرأ من الفأرة. لنبدأ مع بريمج التحكم بالكاميرا CameraTouchInput والذي سنضيفه على قالبي عنصري الخلفية والأرضية حيث يوجد CameraMouseInput أيضا. هذا البريمج موضح في السرد التالي: using UnityEngine; using System.Collections; public class CameraTouchInput : MonoBehaviour { //سرعة تحريك الكاميرا public float movementSpeed = 0.0075f; //سرعة التقريب والإبعاد public float zoomingSpeed = 0.00075f; //هي يلمس اللاعب الشاشة حاليا بإصبع واحد؟ private bool singleTouch = false; //هل يلمس اللاعب الشاشة حاليا بأصبعين private bool doubleTouch = false; //مرجع لبريمج التحكم بالكاميرا CameraControl camControl; //تستدعى مرة عند بداية التشغيل void Start() { //قم بتدمير بريمج استقبال مدخلات الفأرة إن تم اكتشاف نظام تشغيل الهاتف الذكي if (Application.platform == RuntimePlatform.Android) { CameraMouseInput mouseInput = GetComponent<CameraMouseInput>(); Destroy(mouseInput); } camControl = FindObjectOfType<CameraControl>(); } //يتم استدعاؤها مرة عند تصيير كل إطار في وقت متأخر void LateUpdate() { UpdateSingleTouch(); UpdateDragging(); UpdateDoubleTouch(); UpdateZooming(); } //تتأكد من كون اللاعب يلمس الشاشة بإصبع واحد void UpdateSingleTouch() { if (Input.touchCount == 1) { Touch playerTouch = Input.touches[0]; if (playerTouch.phase == TouchPhase.Began) { //قام اللاعب للتو بوضع إصبعه على الشاشة //هل تم وضع الإصبع على هذا الكائن تحديدا؟ Vector2 touchPos = Camera.main.ScreenToWorldPoint(playerTouch.position); //اجلب مكوّن التصادم الخاص بهذا الكائن Collider2D myCollider = GetComponent<Collider2D>(); //قم بتوليد شعاع قصير جدا ابتداء من موقع اللمس وباتجاه الأعلى واليمين //ومن ثم تحقق من كون هذا الشعاع قد اصطدم بمكوّن التصادم الخاص بهذا الكائن RaycastHit2D hit = Physics2D.Raycast(touchPos, Vector2.one, 0.1f); if (hit.collider == myCollider) { //نعم لقد قام اللاعب بلمس هذا الكائن بإصبع واحد singleTouch = true; } } else if (playerTouch.phase == TouchPhase.Ended || playerTouch.phase == TouchPhase.Canceled) { //قام اللاعب للتو برفع إصبعه عن الشاشة singleTouch = false; } } else { //عدد الأصابع على الشاشة لا يساوي 1 أي أنه //لا يوجد هناك لمسة بإصبع واحد singleTouch = false; } } //تقوم بفحص تحريك اللاعب لإصبع واحد على الشاشة void UpdateDragging() { if (singleTouch) { Touch playerTouch = Input.touches[0]; camControl.Move(playerTouch.deltaPosition * -movementSpeed); } } //تقوم بفحص قيام اللاعب باللمس بأصبعين void UpdateDoubleTouch() { //تأكد من عدم وجود لمسة بإصبع واحد حاليا if (!singleTouch) { if (Input.touchCount == 2) { doubleTouch = true; } else { doubleTouch = false; } } } //تقوم بتحديث التقريب والإبعاد مستخدمة حركة الأصبعين على الشاشة void UpdateZooming() { if (doubleTouch) { //على الشاشة A, Bمواقع الأصبعين المسميين //في كل من الإطار الحالي 2 والإطار السابق 1 Vector2 posA1, posA2, posB1, posB2; Touch a, b; a = Input.touches[0]; b = Input.touches[1]; posA2 = a.position; posB2 = b.position; posA1 = a.position - a.deltaPosition; posB1 = b.position - b.deltaPosition; //تأكد من كون المسافة بين الأصابع قد زادت أو قلت منذ //الإطار السابق float currentDist = Vector2.Distance(posA2, posB2); float prevDist = Vector2.Distance(posA1, posB1); //طرح المسافة السابقة من الحالية سيعطينا //الإشارة الصحيحة للتقريب أو الإبعاد camControl.Zoom((currentDist - prevDist) * zoomingSpeed); } } } آلية عمل هذا البريمج تعتمد على أربعة خطوات رئيسية: التحقق من لمس الشاشة بإصبع واحد.ثم التحقق من تحريك الكاميرا باستخدام الإصبع.ثم التحقق من لمس الشاشة بأصبعين.وأخيرا التأكد من تنفيذ التقريب والإبعاد باستخدام الأصبعين.قراءة مدخلات شاشة اللمس تتم كالآتي: أولا نتعرف على عدد اللمسات على الشاشة عن طريق المتغير Input.touchCount، فإذا كان عدد اللمسات يساوي واحدا، فإن هذا يعني أن اللاعب يضع إصبعا على الشاشة واحتمال أن يقوم بتحريك الكاميرا وارد. نستخدم هنا المتغيرين singleTouch و doubleTouch من أجل تخزين عدد اللمسات التي اكتشفناها. هذه اللمسات تكون مخزنة في المصفوفة Input.touches وهي مصفوفة تحتوي على عناصر من نوع Touch. هذا النوع من المتغيرات يحتوي على معلومات عن كل لمسة على الشاشة. أول هذه المعلومات التي سنتعامل معها هي Touch.phase والتي تمثل المرحلة التي تمر بها اللمسة. فبمجرد أن يضع اللاعب إصبعه على الشاشة ستكون المرحلة هي TouchPhase.Began. اللحظة الأولى للمس الشاشة مهمة جدا، حيث أنها اللحظة التي يجب علينا أن نتحقق من كون اللاعب قد وضع إصبعه على عنصر الخلفية أو الأرضية، وبالتالي نحدد إذا ما كنا سنسمح له بتحريك الكاميرا. فنحن لا نرغب بتحريك الكاميرا إذا كان اللاعب قد وضع إصبعه على المقذوف أو على زر الخروج مثلا. التحقق من كون اللاعب قد وضع يده على عنصر الخلفية أو الأرضية يتم عبر الدّالة ()UpdateSingleTouch، حيث علينا أن نقوم أولا بتحويل موقع الإصبع من إحداثيات الشاشة إلى إحداثيات المشهد تماما كما سبق وفعلنا مع مؤشر الفأرة. بعد ذلك نقوم باستخراج مكوّن التصادم الخاص بهذا العنصر ومن ثم نستخدم بث الأشعة Ray Casting. بث الأشعة طريقة يستخدمها محرك الفيزياء من أجل الكشف عن تقاطع خط مستقيم مع كائن ما في المشهد، وسنستخدمها هنا لنرسم خطا قصيرا جدا من موقع اللمس إلى نقطة شديدة القرب منه، ونرى إن كان هذا الخط يتقاطع مع مكوّن التصادم الذي استخرجناه. يتم تنفيذ بث الأشعة عن طريق الدّالة ()Physics2D.Raycast والتي تأخذ 3 متغيرات: المتغير الأول هو موقع بداية الشعاع، والمتغير الثاني هو الاتجاه الذي سيسير فيه هذا الشعاع، بينما يحدد المتغير الثالث وهو قيمة رقمية أقصى مسافة يمكن أن يقطعها هذا الشعاع. وهنا تلاحظ استخدامنا لمسافة قصيرة جدا، وهي كافية حين يكون موقع البداية موجودا أصلا داخل مكوّن التصادم الذي يتم فحص التصادم معه. القيمة التي ترجعها هذه الدّالة هي من نوع RaycastHit2D، وهي تحتوي على متغير لتخزين مكوّن التصادم الذي اصطدم به الشعاع. بعد ذلك نفحص إذا ما كان المكوّن الذي اصطدم به الشعاع هو نفسه مكوّن التصادم الذي استخرجناه من العنصر الحالي، وإذا تحقق هذا الأمر فإننا نعتمد هذا اللمسة من قبل اللاعب على الخلفية أو الأرضية ونسمح له بالتالي بتحريك الكاميرا عن طريق تغيير قيمة singleTouch إلى true. في حالة كانت اللمسة في مرحلة أخرى مثل TouchPhase.Ended أو TouchPhase.Canceled، فإن هذا يعني أن اللاعب قد رفع إصبعه عن الشاشة، وبالتالي نعيد قيمة singleTouch إلى false. الأمر نفسه سيحدث في حال اكتشفنا أن عدد الأصابع الموجودة على الشاشة Input.touchCount لا يساوي واحدا، بالتالي لن نسمح للاعب في هذه الحالة بتحريك الكاميرا. الدّالة ()UpdateDragging هي المسؤولة عن تحريك الكاميرا، لذا عليها أن تتأكد أولا من قيمة singleTouch وبعد ذلك تقوم بتحريك الكاميرا بالمقدار playerTouch.deltaPosition مضروبا في سرعة الحركة. لاحظ أننا هنا لا نحتاج لتخزين موقع اللمسة السابق حيث أن قيمة الإزاحة تأتينا مباشرة بعكس ما كان عليه الحال حين التعامل مع مؤشر الفأرة. بعد ذلك تقوم الدّالة ()UpdateDoubleTouch بالتحقق من كون اللاعب يضع أصبعين على الشاشة، وفي هذه الحالة تقوم مباشرة بتغيير قيمة doubleTouch إلى true. لاحظ أن اللمس بأصبعين ليس له أي مدلول في اللعبة سوى التقريب والإبعاد بخلاف اللمسة الواحدة التي يمكن أن تستخدم لأكثر من غرض. ولهذا السبب لا نحتاج للتحقق من مواقع الأصبعين بل يكفي وجودهما لمنع أي مدخلات أخرى غير التقريب والإبعاد. أخيرا تقوم الدّالة ()UpdateZooming بالتأكد من وجود أصبعين على الشاشة عبر المتغير doubleTouch وبناء عليها تحسب 4 مواقع وهي كالتالي: posA1: موقع الإصبع الأول خلال الإطار السابق.posA2: موقع الإصبع الأول خلال الإطار الحالي.posB1: موقع الإصبع الثاني خلال الإطار السابق.posB2: موقع الإصبع الثاني خلال الإطار الحالي.لاحظ أننا قمنا بطرح الإزاحة من مواقع الأصابع الحالية حتى نحصل على مواقع الأصابع في الإطار السابق، وذلك لأننا لم نقم بتخزين هذه المواقع من الأساس. بعد ذلك نحسب المسافة بين الأصبعين في الإطار السابق prevDist والإطار الحالي currentDist. تذكر أن الدّالة ()Zoom في البريمج CameraControl تقوم بالتقريب إذا أعطيناها قيمة سالبة وبالإبعاد إذا أعطيناها قيمة موجبة. من أجل ذلك نقوم بطرح المسافة السابقة من المسافة الحالية وضرب الناتج في سرعة التقريب والإبعاد zoomingSpeed. بهذه الطريقة نضمن الحصول على قيمة موجبة إذا حرك اللاعب أصبعيه بعيدا عن بعضهما مما يؤدي للتقريب وقيمة سالبة إذا قرب اللاعب أصبعيه من بعضهما مما يؤدي للإبعاد، وهذا السلوك بطبيعة الحال هو المعتاد عند مستخدمي الهواتف الذكية والأجهزة اللوحية. البريمج الثاني الذي سنتناوله في موضوع استقبال مدخلات شاشة اللمس هو الخاص بإطلاق المقذوفات. تذكر أننا قمنا بإضافة بريمج يسمى LauncherMouseInput على قالب مقلاع إطلاق المقذوفات. البريمج الجديد يسمى LauncherTouchInput وسنقوم بإضافته على نفس القالب من أجل تمكين اللاعب من إطلاق المقذوفات عن طريق اللمس. السرد التالي يوضح هذا البريمج: using UnityEngine; using System.Collections; public class LauncherTouchInput : MonoBehaviour { //مرجع لبريمج إطلاق المقذوفات private Launcher launcher; //تستدعى مرة واحدة عند بداية التشغيل void Start () { //تقوم بتدمير بريمج قراءة مدخلات الفأرة في حال اكتشاف نظام تشغيل الهاتف if (Application.platform == RuntimePlatform.Android) { LauncherMouseInput mouseInput = GetComponent<LauncherMouseInput>(); Destroy(mouseInput); } launcher = GetComponent<Launcher>(); } //تستدعى مرة عند تصيير كل إطار void Update () { UpdateTouchStart(); UpdateDragging(); UpdateRelease(); } //تقوم بالتحقق من كون اللاعب قد وضع اصبعا واحدا //على المقذوف الحالي void UpdateTouchStart() { Projectile currentProj = launcher.currentProjectile; if (currentProj == null) { //لا يوجد ما يمكن فعله return; } if (Input.touchCount == 1) { Touch playerTouch = Input.touches[0]; if (playerTouch.phase == TouchPhase.Began) { //قام اللاعب للتو بلمس الشاشة // تحقق من كون عملية اللمس تمت داخل حدود المقذوف Vector2 touchPos = Camera.main.ScreenToWorldPoint(playerTouch.position); //استخرج مكوّن التصادم الخاص بالمقذوف الحالي Collider2D projectileCollider = currentProj.GetComponent<Collider2D>(); if (projectileCollider.bounds.Contains(touchPos)) { //تمت عملية اللمس داخل حدود المقذوف بالتالي يجب إمساكه launcher.HoldProjectile(); } } } } //تقوم بالتحقق من تحريك اللاعب لإصبعه على الشاشة أثناء إمساك المقذوف void UpdateDragging() { if (Input.touchCount == 1) { Vector2 touchWorldPos = Camera.main.ScreenToWorldPoint(Input.touches[0].position); launcher.DragProjectile(touchWorldPos); } } //تتحقق من رفع اللاعب إصبعه عن الشاشة void UpdateRelease() { if (Input.touchCount == 1) { Touch playerTouch = Input.touches[0]; if (playerTouch.phase == TouchPhase.Ended || playerTouch.phase == TouchPhase.Canceled) { launcher.ReleaseProjectile(); } } } } ينفذ هذا البريمج ثلاث خطوات في كل عملية تحديث: الخطوة الأولى عبر الدّالة ()UpdateTouchStart والتي تتحقق من كون اللاعب قد لمس الشاشة للتو مستخدما إصبعا واحدا. في هذه الحالة تقوم الدّالة بحساب موقع اللمسة في فضاء المشهد ومن ثم فحص ما إذا كان هذا الموقع ضمن حدود مكوّن التصادم الخاص بالمقذوف الحالي المتواجد على مقلاع الإطلاق. إذا كانت هذه اللمسة فعلا داخل حدود المقذوف فهذا يعني أن اللاعب يريد إمساكه، وبالتالي تقوم الدّالة بإمساك المقذوف عبر استدعاء ()HoldProjectile من البريمج Launcher.الخطوة الثانية التي تنفذ عبر ()UpdateDragging تتعلق بوجود إصبع واحد على الشاشة، حيث تقوم بحساب موقع الإصبع في فضاء المشهد وتطلب من البريمج Launcher تحريك المقذوف الحالي إلى هذا الموقع. تذكر أن الأمور الأخرى مثل التأكد من وجود مقذوف أو إذا ما كان ممسوكا أو تم إطلاقه تتم من خلال البريمج Launcher نفسه بالتالي لا نحتاج لفحصها هنا، ولعلك تذكر أننا لم نفحصها أيضا في بريمج قراءة مدخلات الفأرة.أخيرا تقوم الدّالة ()UpdateRelease بالتحقق من كون اللاعب قد رفع إصبعا واحدا عن الشاشة، وذلك من خلال فحص مرحلة اللمس playerTouch.phase إذا ما كانت تساوي Ended أو Canceled وهما الحالتان المتوقعتان حال رفع الإصبع. عند التأكد من هذا الأمر تقوم باستدعاء دالّة إطلاق المقذوف ()ReleaseProjectile.البريمج الأخير الذي يقرأ مدخلات شاشة اللمس هو البريمج الخاص بتنفيذ الهجوم الخاص للمقذوف بعد إطلاقه. هذا الهجوم يتم عبر لمس الشاشة مرة واحدة في أي موقع بعد إطلاق المقذوف. هذا البريمج يسمى SpecialAttackTouchInput ويجب أن تتم إضافته لجميع قوالب المقذوفات التي أنشأناها. هذا البريمج موضح في السرد التالي: using UnityEngine; using System.Collections; public class SpecialAttackTouchInput : MonoBehaviour { //تستدعى مرة واحدة عند بداية التشغيل void Start () { //قم بتدمير بريمج قراءة مدخلات الفأرة في حال اكتشاف نظام تشغيل الهاتف if (Application.platform == RuntimePlatform.Android) { SpecialAttackMouseInput mouseInput = GetComponent<SpecialAttackMouseInput>(); Destroy(mouseInput); } } //تستدعى مرة واحدة عند تصيير كل إطار void Update () { if (Input.touchCount == 1) { Touch playerTouch = Input.touches[0]; if(playerTouch.phase == TouchPhase.Began) { SendMessage("PerformSpecialAttack"); } } } } يمكنك ملاحظة مدى بساطة هذا البريمج، حيث أن كل ما يفعله هو إرسال الرسالة PerformSpecialAttack في حال قام اللاعب بلمس الشاشة مرة واحدة. بهذا تكون اللعبة مكتملة لأجهزة الحاسب والهواتف الذكية، وبقي علينا تصديرها على شكل تطبيق مستقل. تصدير اللعبة بعد إكمالهايوفر محرك Unity إمكانية تصدير الألعاب على مجموعة كبيرة من المنصات، وما يهمنا منها الآن هو منصتا الحاسب الشخصي والهواتف الذكية وتحديدا الأجهزة التي تعمل بنظام Android. تصدير اللعبة يتم عن طريق النافذة التي حددنا منها ترتيب المشاهد وهي: File > Build Settings بالنسبة للتصدير للحواسيب وأجهزة Mac اختر من القائمة اليسرى PC, Max & Linux Standalone لتظهر أمامك الخيارات التالية: أهم هذه الخيارات هو Target Platform والذي تحدد من خلاله نظام التشغيل المستهدف وخيار Architecture والذي يحدد نوع النظام التشغيل سواء كان 32 أو 64 بت. من المهم الانتباه إلى أن الخيار x86_64 يجعل اللعبة تعمل فقط على أنظمة 64 بت حيث أن وجود x86 في الاسم قد يضلل أحيانا فتظن أنه يشمل النظامين. إن كنت تستهدف النظامين 32 و 64 فابق على الخيار الأول x86. بعد ذلك يمكنك الضغط على Build ثم تختار مكان التخزين لتبدأ بعدها عملية التصدير. بالنسبة للتصدير للهواتف الذكية الموضوع أصعب قليلا. سأتحدث هنا بالتفصيل عن طريقة التصدير لمنصة Android فقط لأنني للأسف لا أملك خبرة في التعامل مع أنظمة iOS. قبل التصدير لهذا النظام علينا القيام بتجهيز بعض الأدوات. الخطوة الأولى هو تحميل وتثبيت Android SDK وستحتاج هنا للإصدار الخامس حيث أن الإصدار الخامس من محرك Unity يحتاج لهذه النسخة. يمكنك تحميل المكتبات اللازمة من هذا الرابط https://developer.android.com/sdk/index.html#Other. عند التثبيت يفضل أن تختار موقعا سهلا مثل d:\AndroidSDK. بعد تثبيت Android SDK على جهازك عليك أن تقوم بتنزيل مكتبة واحدة على الأقل عن طريق تشغيل SDK Manager وهو التطبيق المصاحب لـ Android SDK. كل ما تحتاج إليه هنا هو اختيار SDK Platform للإصدار الأخير (حتى كتابة هذه الدروس الإصدار الأخير هو API22 5.1.1). وذلك موضح في الصورة التالة: بعد تنزيل وتثبيت المكتبة علينا أن نخبر Unity عن موقعها وذلك عبر الخيار: Edit > Preferences ومن ثم اختيار External Tools من القائمة اليسرى. بعدها يمكنك تحديد المجلد الذي قمت بتثبيت Android SDK فيه عن طريق الخيار Android SDK Location كما ترى هنا: بما أن اللعبة ستعمل على شاشة الهاتف، يجب علينا أن نجبر النظام على تشغيلها في الوضع الأفقي وليس العمودي. هذا الخيار يمكن ضبطه من خلال القائمة: Edit > Project Settings > Player ومن ثم اختيار رمز نظام Android وفتح مجموعة الإعدادات المسماة Resolution and Presentation. بعدها يجب ضبط Default Orientation على الخيار Landscape Left كما هو موضح في الصورة التالية: الخطوة الأخيرة قبل التصدير هي تحديد معرف الحزمة، وهو عبارة عن اسم على شكل عنوان موقع إنترنت معكوس يبدأ بعنوان موقع الناشر وينتهي باسم المنتج، مثل com.Hsoub.Academy.AngryAnimals. قد تبدو هذه الطريقة في التعريف غريبة بعض الشيء، لكنها موروثة من طريقة تعريف الحزم البرمجية بلغة Java والتي تعتبر اللغة الرئيسية لبرمجة التطبيقات على نظام Android. لا يشترط طبعا أن يكون موقع الإنترنت المستخدم حقيقيا، لكن المهم هو أن تتبع هذا التنسيق في تعريف الحزمة. لإضافة معرّف الحزمة ادخل إلى القائمة: Edit > Project Settings > Player ومن ثم اختر رمز نظام Android من نافذة الخصائص وافتح مجموعة الإعدادات المسماة Other Settings كما هو موضح في الصورة التالية: يمكنك بعدها كتابة معرّف الحزمة في الخانة PlayerSettings.bundleIdentifier كما يمكنك تحديد الإصدار الأدنى الذي يدعمه التطبيق. يستحسن أن تضبط التوافق على الإصدار 4.0 من النظام حيث أنه لا زال شائعا في معظم الأجهزة المتوفرة. بعدها يمكنك أخيرا العودة لشاشة التصدير: File > Build Settings واختيار Android من القائمة اليسرى ومن ثم الضغط على Build واختيار موقع التصدير. سيكون الملف الناتج من نوع APK والذي يمكنك نسخه لجهازك المحمول وتنزيله وتشغيله. قد تضطر لتغيير إعدادات الجهاز حتى يسمح بتنزيل تطبيقات مجهولة المصدر (أي من مصدر غير متجر Google Play) وهذه الإعدادات تختلف من جهاز لآخر. بالنسبة لأجهزة Samsung ستجد هذا الخيار في أعدادات الحماية تحت بند إدارة الجهاز كما هو موضح هنا: تهانينا! لقد قمت ببناء وتصدير لعبة كاملة على هاتفك الذكي أو جهازك اللوحي، ويمكنك الاستمتاع بلعبها ومشاركتها مع أصدقائك.
-
مدخل إلى unity3d إعداد المشهد والكاميرا في Unity3D
ياسر جفال علق على مقال ياسر جفال في صناعة الألعاب
العفو، يمكنك تحميل المشروع كاملا من هذا الرابط حيث ستجد جميع السكربتات جاهزة وتعمل وما عليك سوى فتحه باستختدام يونيتي الكود المفرق قد لا يكون دقيقا في بعض الأجزاء لأنه للتوضيح وقد ترافق عملية النسخ من المحرر للويب وبالعكس بعض الأخطاء. يمكنك تحميل الملف Source Code من الرابط أدناه وفتحه مباشرة عن طريق المحرك https://github.com/HsoubAcademy/Unity3D/releases/tag/v0.1.0 -
أشكركم جميعا على الردود المشجعة وأتمنى أن تستفيدوا من باقي دروس السلسلة. سأتابع أسئلتكم وتعليقاتكم على الدروس حال وجود أي استفسارات أو استيضاحات
-
مدخل إلى unity3d إعداد المشهد والكاميرا في Unity3D
ياسر جفال علق على مقال ياسر جفال في صناعة الألعاب
مرحبا، أشكرك على الاهتمام. هل تحاول نسخ السكربت من صفحة الدرس أم أنك قمت بتحميل المشروع كاملا من الملفات المرفقة؟ -
إنشاء المقذوفاتقطعنا حتى الآن شوطا لا بأس به نحو إكمال ميكانيكيات اللعبة، حيث أصبح لدينا مشهد يمكننا التجول فيه وتقريب وإبعاد الكاميرا، إضافة لإمكانية بناء مرحلة داخل هذا المشهد مستخدمين الوحدات البنائية وأشكال الوحوش التي صنعناها. الخطوة التالية هي أن نقوم بصنع مقلاع لإطلاق المقذوفات، بالإضافة إلى المقذوفات نفسها والتي سنستخدم لها صور الحيوانات الموجودة في المجلد Kenney.nl\AnimalPack. هذه الحيوانات موضحة في الصورة التالية: علينا أن نقوم ببناء قالب مقذوف لكل صورة من هذه الصورة الأربعة. هذا القالب سيحتوي مبدئيا على المكوّن Sprite Renderer والذي أصبح معروفا لدينا، إضافة للمكوّنين Rigid Body 2D و Circle Collider 2D. هذه المكوّنات كفيلة بتحويل كل صورة إلى كائن نشط فيزيائيا، وكل ما يلزمنا هو تعديل قيم الكتلة في مكوّن الجسم الصلب لتصبح 5 لكل واحد من هذه الصور، إضافة لتفعيل الخيار Is Kinematic والذي يمنع استجابة الجسم الصلب للقوى الخارجية، وهو ما يلزمنا في البداية وسنقوم بتغييره لاحقا. الكتلة الكبيرة ضرورية لجعل هذه المقذوفات تحدث تأثيرا ملحوظا حين تصطدم بالوحدات البنائية أو الخصوم حال إطلاقها. بعد ذلك علينا أن نبدأ في كتابة وإضافة البريمجات اللازمة لهذه المقذوفات. البداية ستكون مع البريمج الرئيسي والأهم وهو Projectile. والموضح في السرد التالي: using UnityEngine; using System.Collections; public class Projectile : MonoBehaviour { //عدد الثواني التي سيعيشها المقذوف في المشهد بعد إطلاقه public float lifeSpan = 7.5f; //هل يُسمح للاعب بالتحكم بهذا المقذوف حاليا؟ private bool controllable = false; //هل قام اللاعب بإمساك هذا المقذوف وإعداده للإطلاق؟ private bool held = false; //هل تم إطلاق هذا المقذوف بالفعل؟ private bool launched = false; //هل تم تنفيذ الهجوم الخاص بهذا المقذوف؟ private bool attackPerformed = false; //متغير لتخزين موقع المقذوف لحظة إمساك اللاعب له وقبل سحبه استعدادا للإطلاق private Vector2 holdPosition; //يتم استدعاؤها مرة واحدة عند بداية التشغيل void Start () { } //يتم استدعاؤها مرة عند تصيير كل إطار void Update () { } //تسمح للاعب بالتحكم بهذا المقذوف شريطة ألا يكون قد تم إطلاقه فعلا public void AllowControl() { if (!launched) { controllable = true; } } //تستدعى عند بداية إمساك اللاعب للمقذوف استعدادا لإطلاقه public void Hold() { if (controllable && !held && !launched) { held = true; holdPosition = transform.position; //أرسل رسالة تخبر بإمساك اللاعب للمقذوف SendMessage("ProjectileHeld"); } } //تقوم بإطلاق المقذوف مستخدمة مقدار القوة المزود public void Launch(float forceMultiplier) { if (controllable && held && !launched) { //احسب متجه الإطلاق Vector2 launchPos = transform.position; Vector2 launchForce = (holdPosition - launchPos) * forceMultiplier; //أضف قوة الإطلاق المحسوبة للجسم الصلب Rigidbody2D myRB = GetComponent<Rigidbody2D>(); myRB.isKinematic = false; myRB.AddForce(launchForce, ForceMode2D.Impulse); //قم بضبط متغيرات الحالة الجديدة launched = true; held = false; controllable = false; //قم بتدمير المقذوف بعد انقضاء الثواني المحددة لبقائه في المشهد Destroy(gameObject, lifeSpan); //أرسل رسالة تخبر بحدوث الإطلاق SendMessage("ProjectileLaunched"); } } //تقوم بتنفيذ الهجوم الخاص لهذا المقذوف بعد إطلاقه public void PerformSpecialAttack() { if (!attackPerformed && launched) { //اسمح بالهجوم الخاص مرة واحدة فقط attackPerformed = true; SendMessage("DoSpecialAttack", SendMessageOptions.DontRequireReceiver); } } //قم بسحب المقذوف إلى الموقع المحدد شريطة أن يكون قابلا للتحكم به من قبل اللاعب public void Drag(Vector2 position) { if (controllable && held && !launched) { transform.position = position; } } //تخبر ما إذا اللاعب يمسك حاليا بالمقذوف استعدادا لإطلاقه public bool IsHeld() { return held; } //تخبر ما إذا تم إطلاق المقذوف بالفعل public bool IsLaunched() { return launched; } } لاحظ أن المتغير العام الوحيد في هذا البريمج هو lifeSpan والذي يحدد مدة بقاء المقذوف في المشهد بعد أن يتم إطلاقه. بخلاف ذلك لدبنا متغيرات الحالة وهي launched و held و controllable و attackPerformed، وكل هذه المتغيرات خاصة ولا يمكن التحكم بها إلا عن طريق إرسال الرسائل أو استدعاء الدّوال. إضافة لهذا المتغير العام لدينا أربع متغيرات خاصة تعبر عن الأوضاع المختلفة التي يكون فيها المقذوف منذ بداية اللعبة وحتى يتم إطلاقه إلى أن يختفي أخيرا من المشهد. هذه المتغيرات هي controllable و held و launched و attackPerformed وقيمتها المبدئية هي false. أوضاع المقذوف المختلفة تأتي على التسلسل التالي: في بداية اللعبة يكون المقذوف موضوعا على الأرض بجانب مقلاع القذف، وفي هذه الأثناء يبقى ساكنا ولا يستطيع اللاعب التحكم به إلى أن يأتي دوره في الإطلاق. في هذه الحالة تكون قيمة المتغير controllable تساوي false، مما يمنع اللاعب من التحكم بالمقذوف.بمجرد أن يحين دور المقذوف في الإطلاق ويتم وضعه على القاذف، يتم استدعاء الدّالة ()AllowControl، مما يغير قيمة controllable إلى true ويسمح للاعب بالتحكم بالمقذوف. حتى اللحظة تبقى المتغيرات الثلاث الأخرى على قيمتها false.بمجرد أن يقوم اللاعب بالضغط بزر الفأرة على المقذوف، يتم استدعاء الدّالة ()Hold والتي يفترض أن تقوم بتغيير قيمة متغير الإمساك held إلى true. بما أن هذا المتغير يعبر عن أن اللاعب يمسك بالمقذوف، فإنّه ينبغي التأكد أولا من أنه مسموح للاعب بالتحكم به وذلك عن طريق فحص قيمة controllable، كما ينبغي التأكد من أنه غير ممسوك أصلا وذلك بفحص قيمة held نفسها، وأخيرا يجب فحص قيمة launched حتى يتم التأكد من أن المقذوف لم يتم إطلاقه بعد، وذلك لأنه لا يمكن إمساك المقذوف بعد إطلاقه. بعد التحقق من هذه الشروط الثلاث يتم تخزين الموقع الذي أمسك فيه اللاعب المقذوف في المتغير holdPosition ومن ثم يتم إرسال الرسالة ProjectileHeld للتبليغ بأن المقذوف تم إمساكه.بعد الإمساك يبدأ اللاعب بتحريك المقذوف من أجل تجهيزه للإطلاق، حيث يقوم بسحبه للخلف والأسفل استعدادا لإطلاقه. أثناء إمساك اللاعب للمقذوف يُسمح له باستدعاء الدّالة ()Drag والتي يقوم من خلالها بتحريك المقذوف إلى موقع محدد. كما ترى فإنّ عملية التحريك عبر هذه الدّالة تعتمد على كون المقذوف قابلا للتحكم وممسوكا حاليا، إضافة إلى أنه يجب ألا يكون قد تم إطلاقه.عندما يقوم اللاعب بإفلات المقذوف يتم استدعاء دالّة الإطلاق ()Launch والتي يمكن إعطاؤها قيمة رقمية تمثل معامل قوة الإطلاق. هذا المعامل يمكننا من عمل أكثر من مقلاع بقوى إطلاق مختلفة. عند استدعاء هذه الدّالة تقوم بالتحقق من كون المقذوف تحت تحكم اللاعب وأن اللاعب يمسكه حاليا، إضافة إلى أنه لم يتم إطلاقه حتى الآن. بعد تحقق هذه الشروط يتم حساب قوة الإطلاق عن طريق المسافة بين موقع إمساك المقذوف وموقع إفلاته وضربها بالمعامل المزوّد للدّالة، حيث أن شد المقذوف لمسافة أبعد سينتج عنه قوة إطلاق أكبر. بعدها يتم تفعيل الجسم الصلب مرة أخرى عن طريق ضبط المتغير isKinematic إلى false وبالتالي عودة تنشيط استجابة الجسم الصلب للقوى الخارجية قبل إضافة قوة الإطلاق له. بعد تنفيذ الإطلاق يتم تحديث متغيرات الحالة؛ حيث يمنع اللاعب من التحكم بالمقذوف عن طريق تغيير controllable إلى false ويتم تنشيط حالة الإطلاق عن طريق تغيير launched إلى true ويتم أيضا إعادة held إلى false حيث أن اللاعب لم يعد ممسكا بالمقذوف. أخيرا يتم إرسال الرسالة ProjectileLaunched من أجل إبلاغ البريمجات الأخرى بأن المقذوف قد تم إطلاقه.بعد اطلاق المقذوف بقي هناك خطوة أخيرة يمكن للاعب القيام بها بخصوصه، وهي تنفيذ الهجوم الخاص بالمقذوف؛ كالانشطار إلى 3 مقذوفات أصغر أو مضاعفة السرعة أو غيرها. يمكن للاعب تنفيذ هذا الهجوم عن طريق استدعاء الدّالة ()PerformSpecialAttack والتي تتأكد من أن قيمة attackPerformed هي false؛ حيث أن تنفيذ هذا الهجوم مسموح مرة واحدة فقط. إضافة لهذا الشرط يجب التأكد من أن المقذوف تم إطلاقه بالفعل عن طريق فحص المتغير launched؛ ذلك أن هذا الهجوم يمكن تنفيذه فقط بعد إطلاق المقذوف. كما تلاحظ فإنّ هذه الدّالة لا تقوم بتنفيذ الهجوم فعليا، عوضا عن ذلك تقوم بإرسال الرسالة DoSpecialAttack والتي سيقوم بريمج آخر باستقبالها وتنفيذ الهجوم الفعلي بناء عليها. بفصل استدعاء الهجوم عن تنفيذه نواصل عملنا على مبدأ فصل الاهتمامات ونمكّن أنفسنا من برمجة أكثر من نوع هجوم دون أن يؤثر ذلك على هيكل البريمج الأساسي.بخلاف هذه المراحل نلاحظ وجود الدّالتين ()IsHeld و ()IsLaunched واللتين تمكنان البريمجات الأخرى من قراءة قيم المتغيرات الخاصة ولكن دون تغيير قيمتها. قراءة هذين المتغيرين ستكون ذات أهمية للبريمجات التي يعتمد عملها على بريمج المقذوف كما سنرى بعد قليل. ملاحظة أخرى هي استخدام الخيار SendMessageOptions.DontRequireReceiver عند إرسال الرسالة DoSpecialAttack وبذلك لا نشترط وجود مستقبل للرسالة. السبب هو أن هذا الهجوم اختياري ولا بأس من وجود مقذوفات ليس لها أي هجوم خاص. بهذا نكون قد تعرفنا على البريمج الأساسي للمقذوفات، وبقي علينا بعض البريمجات الصغيرة المساعدة للوظائف الثانوية. البريمج الأول هو ProjectileSounds وهو المسؤول عن أصوات المقذوفات. ما يفعله هذا البريمج ببساطة هو استقبال رسالتي الإمساك ProjectileHeld والإطلاق ProjectileLaunched وتشغيل الملف الصوتي المحدد لكل عملية. السرد التالي يوضح هذا البريمج: using UnityEngine; using System.Collections; public class ProjectileSounds : MonoBehaviour { //الملف الصوتي الخاص بعملية الإطلاق public AudioClip launchSound; //الملف الصوتي الخاص بعملية الإمساك public AudioClip holdSound; //تستدعى مرة واحدة عند بداية التشغيل void Start () { } //تستدعى مرة عند تصيير كل إطار void Update () { } void ProjectileHeld() { AudioSource.PlayClipAtPoint(holdSound, transform.position); } void ProjectileLaunched() { AudioSource.PlayClipAtPoint(launchSound, transform.position); } }البريمج الثاني الذي سنناقشه من بريمجات قالب المقذوف هو البريمج الخاص برسم مسار حركة المقذوف بعد إطلاقه. المسار المرسوم سيكون عبارة عن نقاط بينها مسافات ثابتة تمتد على طول المسار الذي قطعه المقذوف ابتداء من لحظة إفلاته عند مقلاع الإطلاق إلى آخر نقطة يصل إليها. قبل شرح البريمج سنقوم ببناء قالب يمثل كائن النقطة التي سنستخدمها لرسم المسار. لبناء القالب كل ما عليك هو إضافة كائن فارغ جديد للمشهد ومن ثم إضافة المكوّن SpriteRenderer إليه. بعد ذلك قم بالضغط على زر الاستعراض للخانة Sprite في المكوّن كما في الصورة، وقم بالنزول لآخر النافذة حيث ستجد في الأسفل مجموعة من الصور الافتراضية التي يستخدمها Unity من أجل بناء واجهة المستخدم. قم باختيار الصورة Knob ومن ثم أغلق النافذة. بعد ذلك قم بتسمية القالب الجديد باسم PathPoint وتقليص حجمه على المحورين x و y إلى 0.75: الآن أصبح بإمكاننا كتابة بريمج رسم المسار وإضافته لقالب المقذوف. هذا البريمج موضح في السرد التالي: using UnityEngine; using System.Collections; public class PathDrawer : MonoBehaviour { //القالب المستخدم لرسم النقاط public GameObject pathPointPrefab; //المسافة بين كل نقطتين متتابعتين public float pointDistance = 0.75f; //الكائن الأب لكائنات نقاط المسار Transform pathParent; //متغير لتخزين موقع آخر نقطة تمت إضافتها Vector2 lastPointPosition; //متغير داخلي لمعرفة ما إذا كان المقذوف قد تم إطلاقه أم لا bool launched = false; //تستدعى مرة واحدة عند بداية التشغيل void Start () { //Path ابحث عن الكائن الأب لنقاط المسار والمسمى pathParent = GameObject.Find("Path").transform; } //تستدعى مرة عند تصيير كل إطار void Update () { if (launched) { float dist = Vector2.Distance(transform.position, lastPointPosition); if (dist >= pointDistance) { //حان الوقت للإضافة نقطة جديدة AddPathPoint(); } } } void ProjectileLaunched() { //تم إطلاق المقذوف للتو لذا احذف المسار السابق for (int i = 0; i < pathParent.childCount; i++) { Destroy(pathParent.GetChild(i).gameObject); } AddPathPoint(); //قم بتحديث قيمة المتغير حيث أن المقذوف قد تم إطلاقه launched = true; } //تقوم بإضافة نقطة جديدة للمسار void AddPathPoint() { //قم بإنشاء نقطة جديدة مستخدما القالب GameObject newPoint = (GameObject)Instantiate(pathPointPrefab); //قم بوضع النقطة في الموقع الحالي للمقذوف newPoint.transform.position = transform.position; //قم بضبط الكائن الأب للنقطة newPoint.transform.parent = pathParent; //قم بتخزين موقع النقطة التي تمت إضافتها lastPointPosition = transform.position; } } يستخدم هذا البريمج القالب الذي أنشأناه للتو من أجل رسم النقاط على طول المسار، لذا سنحتاج لتحديد هذا القالب عبر المتغير pathPointPrefab. بعد ذلك يمكننا عن طريق المتغير pointDistance ضبط المسافة التي نرغب بها بين كل نقطتين متتابعتين، فكلما قلت هذه المسافة زاد عدد النقاط المرسومة. بعد ذلك سنحتاج لمرجع للكائن Path، وهو كائن فارغ علينا إضافته لهرمية المشهد كابن للكائن الجذري. هذا الكائن سيكون هو الأب لجميع نقاط المسار، وهو يساعدنا في الوصول إليها دفعة واحدة لحذفها حين رسم مسار جديد كما سنرى بعد قليل. بما أننا سنحسب المسافة بين كل نقطتين متتابعتين أثناء حركة المقذوف لرسم المسار، علينا دائما أن نكون محتفظين بموقع آخر نقطة تم رسمها. هذا الموقع نخزنه في المتغير lastPointPosition. أخيرا فإننا نعلم أن المسار لا يجب أن يتم رسمه إلا بعد إطلاق المقذوف، لذلك نستخدم المتغير launched لنعرف من خلاله ما إذا تم هذا الإطلاق أم ليس بعد. تذكر أنه عند إطلاق المقذوف سيقوم البريمج Projectile بإرسال الرسالة ProjectileLaunched، والتي يستقبلها البريمج PathDrawer عن طريق الدّالة التي تحمل نفس الاسم. بمجرد وصول الرسالة يتم حذف المسار المرسوم سابقا (إن وُجد) وذلك عن طريق حذف جميع أبناء الكائن الفارغ Path والذي نحتفظ بمرجع له في المتغير pathParent. بعد الانتهاء من الحذف نرسم نقطة في موقع الإطلاق عن طريق استدعاء الدّالة ()AddPathPoint ومن ثم يتم تغيير قيمة launched إلى true. ما تقوم به الدّالة ()AddPathPoint هو إنشاء نقطة جديدة في الموقع الحالي للمقذوف باستخدام القالب pathPointPrefab وإضافتها كابن للكائن Path ومن ثم تخزين موقعها في المتغير lastPointPosition. طالما أن كائن المقذوف موجود في المشهد سيتم استدعاء الدّالة ()Update في كل إطار، إلا أنها لن تقوم بأي شيء إلى أن تتغير قيمة launched إلى true. إذا تحقق هذا الشرط فهذا يعني أن المقذوف قد تم إطلاقه وبالتالي يجب أن يتم رسم المسار أثناء حركته؛ لذلك نقوم بحساب المسافة بين موقع المقذوف الحالي transform.position وموقع رسم النقطة السابقة lastPointPosition. إذا زادت هذه المسافة أو تساوت مع pointDistance فإن الوقت يكون قد حان لإضافة نقطة جديدة لهذا يتم استدعاء ()AddNewPoint. الصورة التالية تمثل عملية رسم مسار المقذوف أثناء حركته: الهجمات الخاصة للمقذوفاتلإكمال المقذوفات علينا صناعة الهجوم الخاص الذي يمكن للاعب تنفيذه بعد إطلاق المقذوف. هذا الهجوم له صور متعددة في اللعبة الأصلية Angry Birds والتي نقتبس منها في سلسلة الدروس هذه. سنكتفي نحن بمثالين لتوضيح كيفية بناء هذه الهجمات. الأول هو هجوم السرعة والذي سنعتمده للمقذوفات التي على شكل طيور، والذي يقوم بمضاعفة سرعة المقذوف مما يجعل تأثيره أكبر حين يصطدم بالوحدات البنائية أو الخصوم. الهجوم الثاني سنعتمده لمقذوفي الزرافة والفيل وهو الهجوم الانشطاري، حيث ينقسم المقذوف الأصلي إلى عدد من المقذوفات الأصغر حجما والتي يمكنها إصابة أكثر من هدف في أماكن متفرقة. لنبدأ مع الهجوم الأسهل وهو هجوم السرعة. نظرا لكون منطق الهجمات يختلف تماما بين هجوم وآخر، علينا أن نفصل كل هجوم في بريمج منفصل. العامل المشترك الوحيد بين هذه الهجمات هو أنها ستستقبل الرسالة DoSpecialAttack والتي يرسلها بريمج المقذوف Projectile عندما يتم استدعاء الدّالة ()PerformSpecialAttack ويتم التحقق من الشروط اللازمة لتنفيذ هذا الهجوم. بريمج تنفيذ هجوم السرعة يسمى SpeedAttack، وما يفعله هو جلب مكوّن الجسم الصلب ومن ثم مضاعفة سرعته بمقدار محدد دون تغيير اتجاهها. هذا البريمج موضح في السرد التالي. تذكر أن بريمجات الهجمات يجب أن تتم إضافتها إلى قوالب المقذوفات. using UnityEngine; using System.Collections; public class SpeedAttack : MonoBehaviour { //قم بضرب السرعة الحالية للمقذوف بهذا //المقدار عند تنفيذ الهجوم public float speedFactor = 1.5f; //يتم استدعاؤها مرة عند بداية التشغيل void Start () { } //يتم استدعاؤها مرة عند تصيير كل إطار void Update () { } //وبناء عليها تنفّذ هجوم السرعة DoSpecialAttack تقوم باستقبال الرسالة public void DoSpecialAttack() { //اجلب مكوّن الجسم الصلب لكائن المقذوف Rigidbody2D myRB = GetComponent<Rigidbody2D>(); //قم بضرب السرعة بمقدار المضاعفة ومن ثم اضبط سرعة الكائن على الناتج الجديد myRB.velocity = myRB.velocity * speedFactor; } } النوع الثاني من الهجمات الخاصة كما ذكرنا هو الهجوم الانشطاري، والذي يؤدي إلى تفتت المقذوف إلى مقذوفات أصغر حجما (شظايا)، والتي بدورها تتناثر على مساحة واسعة نسبيا. قبل الانتقال إلى البريمج الخاص بهذا الهجوم، نلاحظ أن تنفيذه سيحتاج لإنشاء كائنات جديدة وهي الشظايا التي ستتناثر جراء تنفيذ الهجوم. معنى هذا أننا سنحتاج لبناء قوالب لهذه الشظايا، وسيكون هناك قالبان تحديدا: واحد لشظايا مقذوف الفيل والآخر لشظايا مقذوف الزرافة. سأسمي هذين القالبين ElephantCluster و GiraffeCluster، وهما فعليا يتشابهان في كل شيء باستثناء الصورة المعروضة. هذان القالبان بسيطان حيث يحمل كل منهما صورة المقذوف الأصلي مع تصغير قياس الكائن إلى 0.75 على المحورين x و y وذلك لجعل الشظايا أصغر من المقذوف الأصلي. إضافة لذلك سنضيف مكوّن جسم صلب Rigid Body 2D و مكوّن تصادم Circle Collider 2D، وبهذا يصبح قالبا الشظايا جاهزين. البريمج الذي سيطبق هذا النوع من الهجوم يسمى ClusterAttack وهو موضح في السرد التالي: using UnityEngine; using System.Collections; public class ClusterAttack : MonoBehaviour { //القالب الذي سيستخدم لإنشاء الشظايا public GameObject clusterPrefab; //عدد الثواني التي ستعيشها كل شظية قبل تدميرها وحذفها من المشهد public float clusterLife = 4.0f; //كم عدد الشظايا التي ستنتج من هذا المقذوف public int clusterCount = 3; //تستدعى مرة واحدة عند بداية التشغيل void Start () { } //تستدعى مرة عند تصيير كل إطار void Update () { } //وبناء عليها تنفذ الهجوم الانشطاري DoSpecialAttack تعمل على استقبال الرسالة public void DoSpecialAttack() { //اجلب السرعة الحالية للمقذوف الأصلي Rigidbody2D myRB = GetComponent<Rigidbody2D>(); float originalVelocity = myRB.velocity.magnitude; //قم بتخزين جميع مكوّنات التصادم الخاصة بالشظايا في هذه المصفوفة Collider2D[] colliders = new Collider2D[clusterCount]; Collider2D myCollider = GetComponent<Collider2D>(); for (int i = 0; i < clusterCount; i++) { //قم بإنشاء شظية جديدة GameObject cluster = (GameObject)Instantiate(clusterPrefab); //قم بضبط الموقع والاسم والأب لكائن الشظية cluster.transform.parent = transform.parent; cluster.name = name + "_cluster_" + i; cluster.transform.position = transform.position; //قم بتخزين مكوّن تصادم الشظية في الموقع الحالي في المصفوفة colliders[i] = cluster.GetComponent<Collider2D>(); //أهمل التصادم الذي يمكن أن يحص بين هذه الشظية والشظايا التي تم إنشاؤها قبلها //إضافة إلى التصادم الذي يمكن أن يقع بين الشظية والكائن الأصلي Physics2D.IgnoreCollision(colliders[i], myCollider); for (int a = 0; a < i; a++) { Physics2D.IgnoreCollision(colliders[i], colliders[a]); } Vector2 clusterVelocity; //مع كل شظية جديدة نقوم بتقليل مركبة السرعة أفقيا وزيادتها عموديا من أجل ضمان تشتت الشظايا clusterVelocity.x = (originalVelocity / clusterCount) * (clusterCount - i); clusterVelocity.y = (originalVelocity / clusterCount) * -i; //اجلب كائن الجسم الصلب للشظية الجديدة Rigidbody2D clusterRB = cluster.GetComponent<Rigidbody2D>(); clusterRB.velocity = clusterVelocity; //قم بتحديد كتلة الشظية لتساوي كتلة الكائن الأصلي clusterRB.mass = myRB.mass; //قم بتدمير الشظية بعد انقضاء العمر المحدد لها Destroy(cluster, clusterLife); } //قم أخيرا بتدمير كائن المقذوف الأصلي Destroy(gameObject); } } فكرة عمل هذا الهجوم تقوم على استقبال الرسالة DoSpecialAttack ومن ثم إنشاء العدد المحدد من الشظايا باستخدام القالب المحدد لها. من أجل منع التصادم بين الشظايا وبعضها وأيضا بين الشظايا والمقذوف الأصلي - حيث من الممكن حدوث تصادم في اللحظة التي تسبق حذفه من المشهد – نقوم باستخدام الدّالة ()Physics2D.IgnoreCollision ونزودها بمكوّني التصادم اللذين نرغب بإهمال التصادمات بينها. لاحظ أننا عرفنا مصفوفة من مكوّنات التصادم من أجل تخزين مكوّنات جميع الشظايا، وعند إنشاء شظية جديدة نمر على مكوّنات تصادم الشظايا السابقة ونستدعي الدّالة المذكورة بين المكوّنين القديم والجديد من أجل إهمال التصادمات. الخطوة التالية تتعلق بسرعة حركة الشظية، حيث نأخذ مقدار سرعة المقذوف الأصلي ونضربه كل مرة بقيمة مختلفة لنحصل على المركّبتين الأفقية والعمودية للسرعة الجديدة. هاتان المركبتان تتغيران من شظية لأخرى، حيث تبدأ الشظية الأولى بمركبة أفقية عالية وعمودية منخفضة، ومن ثم تبدأ هذه القيم بالتغير حيث تزداد القيمة العمودية نحو الأسفل تدريجيا وتقل الأفقية، مما ينتج عنه تشتت المقذوفات بطريقة تشبه ما تراه في الصورة أدناه (قمت في هذه الصورة بزيادة عدد الشظايا لتوضيح الفكرة): لاحظ أن الشظايا تنتشر مبتعدة عن بعضها انطلاقا من موقع انشطار المقذوف الأصلي. بعد ذلك نقوم بضبط كتلة كل شظية لتصبح مساوية لكتلة المقذوف الأصلي. صحيح أنه من المنطقي أن نقوم بقسمة الكتلة على عدد الشظايا من أجل توزيعها بشكل متساو، إلا أن نسخ الكتلة الأصلية لجميع الشظايا سيعطيها قوة تدمير أكبر مما يمنح الهجوم الخاص أفضليته المرجوة. صناعة قاذف الإطلاقلننتقل الآن إلى القاذف وهو المقلاع الذي سيطلق هذه المقذوفات نحو أهدافها. للأسف فإن حزمة الرسومات التي بين أيدينا لا تحتوي على صورة مقلاع، لذا سنحاول استخدام بعض الأشكال الخشبية والمعدنية لصنع شكل بسيط يشبهه. سأستخدم هنا 3 مستطيلات خشبية ومثلثا حجريا لصنع الشكل الذي تراه في الصورة التالية. هذه الكائنات يجب أن توضع كأبناء لكائن فارغ واحد يحتويها جميعا، ويجب أيضا تعديل قيمة Order in Layer في مكوّن Sprite Renderer الخاص بالمثلث وجعلها 1، وذلك حتى يظهر أمام القطع الخشبية كما هو موضح في الصورة: إضافة للصور الأربعة التي قمنا بتحجيمها وتدويرها لصنع المقلاع، هناك 3 كائنات فارغة تشير الخطوط الملونة إلى مواقعها. هذه الكائنات الفارغة ذات فائدة برمجية سنراها بعد قليل. حيث يمثل الكائن LaunchPos مكان وضع المقذوف قبل إطلاقه، ويمثل الكائنان RightEnd و LeftEnd موقعي طرفي الشريط المطاطي الذي سيدفع المقذوفات حين إطلاقها. المقلاع بشكله الحالي جاهز لصنع النسخة الأولية من القالب، والتي سنقوم بإضافة بعض البريمجات والمكوّنات الأخرى إليها. أول هذه البريمجات هو البريمج الرئيسي الذي يعمل على إطلاق المقذوفات على الأهداف. لنتعرف على هذا البريمج المسمى Launcher في السرد التالي ومن ثم نناقش تفاصيل وظائفه: using UnityEngine; using System.Collections; public class Launcher : MonoBehaviour { //معامل قوة الإطلاق الخاصة بهذا القاذف public float launchForce = 1.0f; //أقصى طول يمكن لحبل القاذف المطاطي أن يمتد إليه public float maxStretch = 1.0f; //الموقع الذي سيتم وضع المقذوف الحالي فيه قبل أن يمسكه اللاعب public Transform launchPosition; //المقذوف الحالي الموضوع على القاذف public Projectile currentProjectile; //هل تم إطلاق جميع المقذوفات الموجودة في المشهد؟ private bool projectilesConsumed = false; //تستدعى مر واحدة عند بداية التشغيل void Start () { } //تستدعى مرة عند تصيير كل إطار void Update () { if (projectilesConsumed) { //لا يوجد ما يمكن فعله return; } if (currentProjectile != null) { //إن لم يكن المقذوف الحال قد تم إطلاقه ولم يتم أيضا إمساكه من قبل اللاعب //فقم حينها بجلب المقذوف إلى الموقع المخصص للإطلاق if (!currentProjectile.IsHeld() && !currentProjectile.IsLaunched()) { BringCurrentProjectile(); } } else { //لا يوجد أي مقذوف حاليا على القاذف //قم بالبحث عن أقرب مقذوف وإحضاره لموقع الإطلاق currentProjectile = GetNearestProjectile(); if (currentProjectile == null) { //تم استهلاك كل المقذوفات، أرسل رسالة تخبر بذلك projectilesConsumed = true; SendMessageUpwards("ProjectilesConsumed"); } } } //تقوم بالبحث عن أقرب مقذوف للقاذف وإرجاعه Projectile GetNearestProjectile() { Projectile[] allProjectiles = FindObjectsOfType<Projectile>(); if (allProjectiles.Length == 0) { //لم يعد هناك أي مقذوفات return null; } //قم بالبحث عن أقرب مقذوف وإرجاعه Projectile nearest = allProjectiles[0]; float minDist = Vector2.Distance(nearest.transform.position, transform.position); for (int i = 1; i < allProjectiles.Length; i++) { float dist = Vector2.Distance(allProjectiles[i].transform.position, transform.position); if (dist < minDist) { minDist = dist; nearest = allProjectiles[i]; } } return nearest; } //تقوم بتحريك المقذوف الحالي خطوة واحدة وبشكل سلس نحو موقع الإطلاق void BringCurrentProjectile() { //اجلب المواقع التي سيتحرك المقذوف بينها Vector2 projectilePos = currentProjectile.transform.position; Vector2 launcherPos = launchPosition.transform.position; if (projectilePos == launcherPos) { //المقذوف في موقع الإطلاق فعلا، لا داعي لتحريكه return; } //استخدم الاستيفاء الخطي مع الوقت المنقضي بين الإطارات من أجل الحركة السلسة projectilePos = Vector2.Lerp(projectilePos, launcherPos, Time.deltaTime * 5.0f); //ضع المقذوف في موقعه الجديد currentProjectile.transform.position = projectilePos; if (Vector2.Distance(launcherPos, projectilePos) < 0.1f) { //المقذوف أصبح قريبا جدا، ضعه مباشرة في موقع الإطلاق currentProjectile.transform.position = launcherPos; currentProjectile.AllowControl(); } } //تقوم بإمساك المقذوف الحالي public void HoldProjectile() { if (currentProjectile != null) { currentProjectile.Hold(); } } //تقوم بسحب المقذوف الحالي لموقع جديد public void DragProjectile(Vector2 newPosition) { if (currentProjectile != null) { //تأكد من عدم تجاوز الحد الأقصى لشد الحبل المطاطي float currentDist = Vector2.Distance(newPosition, launchPosition.position); if (currentDist > maxStretch) { //قم بتغيير الموقع المزود إلى أبعد نقطة مسموح بها على امتداده float lerpAmount = maxStretch / currentDist; newPosition = Vector2.Lerp(launchPosition.position, newPosition, lerpAmount); } //ضع المقذوف في الموقع الجديد currentProjectile.Drag(newPosition); } } //تقوم بإفلات المقذوف الحالي وإطلاقه إذا كان اللاعب يمسكه public void ReleaseProjectile() { if (currentProjectile != null) { currentProjectile.Launch(launchForce); } } } المتغيرات العامّة في هذا البريمج هي launchForce والذي يمثل قوة الإطلاق و maxStretch وهو أقصى مسافة مسموح بها بين المقذوف وموقع الإطلاق أثناء الشد (أي أقصى امتداد للخيط المطاطي) و launchPosition وهو متغير لتخزين كائن موقع الإطلاق المسمى LaunchPos الذي سبق وأضفناه لقالب المقلاع حين قمنا بإنشائه. أخيرا لدينا مرجع للمقذوف الحالي الموجود على القاذف وهو currentProjectile. في كل دورة تحديث تقوم الدّالة ()Update بفحص ما إذا كان هناك مقذوف حالي أم لا، وفي حالة عدم وجوده فإنها تقوم باستدعاء الدّالة ()GetNearestProjectile والتي تبحث عن أقرب المقذوفات للقاذف وتقوم بإرجاعه. إذا لم تجد هذه الدّالة أية مقذوفات في المشهد فإنها تعيد القيمة null وفي هذه الحالة يقوم البريمج بإرسال الرسالة ProjectilesConsumed نحو أعلى هرمية المشهد من أجل إبلاغ البريمجات التي تتحكم بحالة اللعبة بأن اللاعب قد استنفد جميع مقذوفاته في هذه المرحلة. حين استنفاد جميع المقذوفات يتم تغيير قيمة المتغير projectilesConsumed إلى true مما يعني أنّ الدّالة ()Update لن تقوم بعمل أي شيء بعد الآن. أمّا في حالة وجود مقذوف حالي لم يقم اللاعب بإمساكه أو إطلاقه بعد، فإن ()Update تقوم باستدعاء الدّالة ()BringCurrentProjectile والتي تعمل على تحريك المقذوف الحالي نحو موقع الإطلاق بشكل سلس (تذكر أن الموقع الأصلي للمقذوفات هو على الأرض بجانب المقلاع). عند وصول المقذوف للموقع launchPosition فإنّ هذه الدّالة ستتوقف تلقائيا عن تحريكه حتى لو استمر استدعاؤها من قبل ()Update. سأتحدث هنا بقليل من التفصل عن الدّالة ()BringCurrentProjectile حتى أشرح الآلية التي تستخدمها من أجل تحقيق الحركة السلسة للمقذوف من موقعه الحالي باتجاه موقع الإطلاق. الحركة السلسة في محركات الألعاب تعتمد على عامل الزمن المنقضي بين كل إطارين متتابعين، وهو في Unity المتغير Time.deltaTime. إضافة لهذا المتغير سنحتاج لدّالة تحسب الاستيفاء الخطي Linear Interpolation بين قيمتين مختلفتين. لكن ما هو الاستيفاء الخطي؟ هو ببساطة عبارة عن قيمة محصورة بين حدين أسفل وأعلى. هذه القيمة قد تكون رقما مجردا بين رقمين، أو موقعا بين موقعين، أو لونا بين لونين، إلخ. لكن أين تقع هذه القيمة بالتحديد بين الحدين؟ ما يحدد هذا الموقع هو قيمة الاستيفاء، وهي قيمة كسرية بين الصفر والواحد، فكلما زادت القيمة كان الناتج أقرب للحد الأعلى، وكلما قلت كان الناتج أقرب للحد الأدنى وهكذا. فمثلا لو أردنا حساب الاستيفاء بين العددين صفر و 10، وكانت قيمة الاستيفاء هي 0.6، فإن الناتج سيكون العدد 6، وإذا كانت قيمة الاستيفاء 0.45 فإنّ الناتج سيكون العدد 4.5 وهكذا. نقوم بحساب الموقع الجديد للمقذوف أثناء سيره باتجاه موقع الإطلاق مستخدمين هذه التقنية، وفي هذه الحالة فإن الاستيفاء يتم بين الحد الأدنى وهو الموقع الحالي للمقذوف projectilePos والحد الأقصى أي الهدف الذي نرغب بالوصول إليه وهو موقع الإطلاق launcherPos. قيمة الاستيفاء قليلة نسبيا أي أنها أقرب للهدف وهي عبارة عن الوقت المنقضي منذ تصيير الإطار السابق مضروبا في 5. أهمية استخدام قيمة الوقت هنا تكمن في حقيقة أن الإطارات لا يتم تصييرها جميعا بنفس السرعة، فهناك عوامل عديدة تؤثر في سرعة الأداء بالتالي فإن الزمن بين الإطارات ليس ثابتا وقد يزيد وينقص. بالتالي وللحفاظ على سرعة حركة ثابتة علينا أن نضرب بقيمة الوقت والتي تزيد كلما قل عدد الإطارات في الثانية الواحدة وتقل بالعكس، مما يجعل سرعة الحركة التي يراها اللاعب ثابتة بغض النظر زاد عدد الإطارات في الثانية أم قل. الدّوال الثلاث ()HoldProjectile و ()DragProjectile و ()ReleaseProjectile تقوم باستدعاء دوال الإمساك ()Hold و التحريك ()Drag و الإطلاق ()Launch للمقذوف الحالي currentProjectile. الدّالة التي تحتاج لبعض الشرح هنا هي ()DragProjectile وذلك لأنها تحتوي على خطوة إضافية غير موجودة في البريمج Projectile، ألا وهي التحقق من كون بعد المقذوف عن موقع الإطلاق الأولي أثناء سحبه للخلف لا يتجاوز الطول المسموح به لتمدد الحبل المطاطي للمقلاع. هذا التمدد معرّف في المتغير maxStretch. الطريقة التي سنعتمدها لفرض هذا الحد الأقصى للطول يجب أن تراعي سهولة التحكم أيضا، فلو سحب اللاعب المؤشر أبعد من الطول المسموح يجب ألا ينسحب معه المقذوف، إلا أنه في نفس الوقت يجب أن يبقى قادرا على تغيير زاوية الإطلاق. من أجل تحقيق هذه الآلية سنستخدم الاستيفاء الخطي مرة أخرى وهذا الاستخدام موضح في السطرين 131 و 132. الفكرة هي أن نحسب المسافة بين موقع الإطلاق والموقع الحالي للمؤشر currentDist ومقارنته بالحد الأقصى للتمدد وهو maxStretch. فإذا تجاوزت هذه المسافة الحد المسموح، سنقوم بقسمة maxStretch على currentDist، وبالتالي نحصل على نسبة الاستيفاء اللازمة بين موقع الإطلاق الأصلي launchPosition.position و الموقع الحالي للمؤشر newPosition. بطبيعة الحال ستقل هذه القيمة بزيادة بعد المؤشر عن موقع الإطلاق، وبالتالي تحافظ على مسافة ثابتة عن موقع الإطلاق وهي المسافة التي تساوي maxStretch. وبتنفيذ الاستيفاء نحصل على الموقع الجديد الصحيح newPosition دون أن نؤثر على سلاسة الحركة، ومن ثم نستخدم الدّالة ()Drag لتحريك المقذوف. من الضروري استخدام هذه الدّالة وليس تحريك المقذوف مباشرة وذلك لأنها تتحقق من الشروط من حيث كون المقذوف ممسوكا من قبل اللاعب ولم يتم إطلاقه، وهي شروط الحركة حسب قواعد اللعبة. بعد الانتهاء من كتابة البريمج علينا أن نضيفه إلى الكائن الفارغ Launcher وهو الجذر لكل كائنات القطع التي يتكون منها المقلاع. البريمج التالي الذي سنضيفه سيقوم برسم الحبل المطاطي بين طرفي المقلاع والمقذوف. لكن قبل الانتقال للبريمج علينا أن نضيف المكوّن المسؤول عن رسم الخط الذي سيمثل هذا الحبل. المكوّن الذي سنضيفه يسمى Line Renderer ويمكن إضافته كالعادة من الزر Add Component ومن ثم كتابة اسم المكوّن كما في الصورة التالية. يقوم هذا المكوّن برسم خط متصل بين مجموعة من النقاط المحددة له عبر المصفوفة positions مبتدئا بأول نقطة في المصفوفة إلى آخر نقطة: بعد إضافة المكوّن علينا أن نعدل بعض قيمه: فأولا علينا تغيير عدد النقاط التي ترسم الخط positions إلى 3 ومن ثم نجعله أقل سماكة عن طريق تغيير كل من Start Width و End Width إلى 0.1، وأخيرا سنقوم بتغيير لونه للأحمر عند بدايته ونهايته (يمكنك بالطبع اختيار أي لون آخر). هذه الإعدادات موضحة في الصورة التالية: لننتقل الآن للبريمج LauncherRope وهو المسؤول عن رسم هذا الخط بين طرفي المقلاع والمقذوف. هذا البريمج موضح في السرد التالي: using UnityEngine; using System.Collections; public class LauncherRope : MonoBehaviour { //موقع الطرف الأيسر للحبل public Transform leftEnd; //موقع الطرف الأيمن للحبل public Transform rightEnd; //مرجع لبريمج القاذف Launcher launcher; //مرجع لمكوّن تصيير الخط المضاف للكائن LineRenderer line; //تستدعى مرة عند بداية التشغيل void Start () { launcher = GetComponent<Launcher>(); line = GetComponent<LineRenderer>(); //قم بإخفاء الخط في البداية بتعطيل مكوّنه line.enabled = false; } //تستدعى مرة عند تصيير كل إطار void Update () { //أظهر الخط فقط في حال كان المقذوف ممسوكا من قبل اللاعب if (launcher.currentProjectile != null && launcher.currentProjectile.IsHeld()) { if (!line.enabled) { line.enabled = true; } //قم برسم الخط ابتداء من الطرف الأيسر فالمقذوف فالطرف الأيمن line.SetPosition(0, leftEnd.position); line.SetPosition(1, launcher.currentProjectile.transform.position); line.SetPosition(2, rightEnd.position); } else { line.enabled = false; } } } يمكنك أن تلاحظ مدى بساطة هذا البريمج، فكل ما يقوم به هو تعطيل مكوّن رسم الخط في البداية، ومن ثم يقوم بفحص حالة المقذوف الحالي (إن وُجد). في حال كان هذا المقذوف ممسوكا من قبل اللاعب يتم تفعيل المكوّن LineRenderer مما يجعل الخط مرئيا، ومن ثم يقوم بضبط مواقع رسم الخط. تذكر الكائنين الفارغين اللذين أضفناهما كإبنين للمقلاع وهما RightEnd و LeftEnd. سنستخدم المرجعين leftEnd و rightEnd المعرّفين في البريمج ونربطهما عن طريق المستعرض بهذين الكائنين. بالتالي فإننا نكون قد حددنا موقع النقطة الأولى والأخيرة للخط المرسوم. بقي أن نحدد موقع النقطة الوسطى وهي بطبيعة الحال موقع المقذوف. لاحظ أننا نستخدم الدّالة ()SetPosition ونعطيها ترتيب الموقع في المصفوفة متبوعا بالنقطة التي نريد أن يكون فيها هذا الموقع. عند تشغيل اللعبة وإمساك المقذوف سيظهر هذا الخط بالشكل التالي: بهذا تكون مهام مقلاع القذف المطلوبة قد اكتملت، وبقي علينا أن نضيف بريمجا لقراءة مدخلات اللاعب بحيث يتمكن من استخدام الفأرة من أجل إطلاق المقذوفات. هذا البريمج يسمى LauncherMouseInput ومهمته قراءة مدخلات الفأرة من اللاعب وتحويلها لأوامر للبريمج Launcher. السرد التالي يوضح هذا البريمج: using UnityEngine; using System.Collections; public class LauncherMouseInput : MonoBehaviour { //مرجع لبريمج الإطلاق private Launcher launcher; //تستدعى مرة واحدة عند بداية التشغيل void Start () { launcher = GetComponent<Launcher>(); } //تستدعى مرة عند تصيير كل إطار void Update () { CheckButtonDown(); CheckDragging(); CheckButtonUp(); } void CheckButtonDown() { if (Input.GetMouseButtonDown(0)) { //تم ضغط زر الفأرة الأيسر للتو //هل يوجد مقذوف حالي؟ if (launcher.currentProjectile != null) { //قم بتحويل موقع المؤشر من إحداثيات الشاشة إلى إحداثيات فضاء المشهد Vector2 mouseWorldPos = Camera.main.ScreenToWorldPoint(Input.mousePosition); //قم باستخراج مكوّن التصادم من الكائن Collider2D projectileCol = launcher.currentProjectile.GetComponent<Collider2D>(); //هل يقع مؤشر الفأرة ضمن حدود مكوّن التصادم الخاص بالمقذوف؟ if (projectileCol.bounds.Contains(mouseWorldPos)) { //نعم، أي أنه تم ضغط زر الفأرة فوق المقذوف //قم بإمساك المقذوف launcher.HoldProjectile(); } } } } //تقوم بفحص ما إذا كان اللاعب يسحب الفأرة مستخدما الزر الأيسر void CheckDragging() { if (Input.GetMouseButton(0)) { Vector2 mouseWorldPos = Camera.main.ScreenToWorldPoint(Input.mousePosition); launcher.DragProjectile(mouseWorldPos); } } //تفحص ما إذا كان قد تم رفع الضغط عن زر الفأرة الأيسر void CheckButtonUp() { if (Input.GetMouseButtonUp(0)) { //تم رفع الضغط عن الزر الأيسر //قم بإطلاق المقذوف launcher.ReleaseProjectile(); } } } تقوم دالّة التحديث ()Update في هذا البريمج باستدعاء ثلاث دوال أخرى على الترتيب وهي ()CheckButtonDown ثم ()CheckDragging ثم ()CheckButtonUp. في الدّالة ()CheckButtonDown يتم التأكد أولا من وجود مقذوف على المقلاع، فإن وُجد هذا المقذوف يتم تحويل موقع مؤشر الفأرة من إحداثيات الشاشة إلى إحداثيات المشهد، ومن ثم فحص ما إذا كان هذا الموقع يقع ضمن حدود مكوّن التصادم الخاص بالمقذوف. تحقق هذا الشرط معناه أن اللاعب قد ضغط بزر الفأرة الأيسر على المقذوف وبالتالي فإنه أمسكه، لذا يتم استدعاء الدّالة ()Hold من البريمج Launcher. في الدّالة ()CheckDragging يتم التحقق من بقاء زر الفأرة الأيسر مضغوطا، وفي هذه الحالة يتم تحريك المقذوف إلى موقع المؤشر عبر استدعاء الدّالة ()DragProjectile. تذكر أنّ هذه الدّالة تمنع تجاوز المقذوف للحد الأقصى لتمدد الحبل المطاطي، بالتالي وبغض النظر عن موقع المؤشر سيبقى المقذوف ضمن هذا الحد. أخيرا فإنّ الدّالة ()CheckButtonUp تقوم بفحص ما إذا أفلت اللاعب زر الفأرة الأيسر، وفي هذه الحالة تستدعي الدّالة ()ReleaseProjectile من بريمج القاذف حتى يتم إطلاق المقذوف. الشكل التالي يمثل المكوّنات النهائية لقالب مقلاع الإطلاق: حسنا، لدينا الآن خلفية وأرضية ووحدات بنائية وخصوم وقاذف ومقذوفات، أي أن جميع عناصر اللعبة باتت جاهزة، ويمكننا أن نجرب بناء مشهد واللعب به. الصورة التالية توضح المرحلة المتقدمة التي وصلنا لها بعد هذا الجهد المضني!
-
بعد أن انتهينا من الكائنات والقوالب اللازمة للعبة، سنقوم بإضافة مشهد جديد للمشروع من أجل صنع القائمة الرئيسية، ومن ثم سنقوم بإضافة بعض النوافذ والأزرار التي تسمح للاعب بالتنقل بين اللعبة والقائمة الرئيسية. ملاحظة: يمكن تحميل الملفات المصدرية لكامل هذه السلسلة عبر حساب أكاديمية حسوب على Github، يمكن أيضا تحميل ملف APK لتجريب اللعبة على أجهزة Android. بناء الشاشة الرئيسية والواجهةلإضافة مشهد جديد اضغط Control+N ومن ثم قم بحفظ هذا المشهد باسم MenuScene. آلية التنقل بين المشاهد في محرك Unity تعتمد على رقم كل مشهد في ترتيب بناء اللعبة. هذا الترتيب يمكن الوصول إليه عن طريق القائمة: File > Build Settings النافذة التي ستظهر هي المسؤولة عن ترتيب المراحل في اللعبة وتصديرها. ما يهمنا الآن هو الترتيب. قم بسحب المشهدين MenuScene و GameScene من مستعرض المشروع إلى القائمة Scenes in Build كما ترى في الصورة التالية. من المهم هنا مراعاة الترتيب حيث يأخذ المشهد MenuScene الرقم 0 والمشهد GameScene الرقم 1. بعد إضافة المشاهد قم بإغلاق النافذة حيث سيتم الحفظ تلقائيا. بعد ذلك سنقوم ببناء القائمة الرئيسية للعبة، والتي ستحتوي على زرين هما "ابدأ اللعب" و "خروج". الأول سيعرض للاعب مجموعة من المراحل ليختار أحدها ليلعبها، والثاني سيخرج من البرنامج نهائيا. إضافة لذلك سنضيف نصا على الشاشة وهو اسم اللعبة وليكن "الحيوانات الغاضبة" مثلا. سنكرر هذا النص مرتين بحيث نضع نسخة أمام الأخرى ونصغرها ومن ثم نغير ألوانهما لدرجات مختلفة. هذا سيؤدي لأن يظهر النص الخلفي كأنه ظل. لنر الآن كيف تتم إضافة كائنات واجهة المستخدم في Unity. عناصر واجهة المستخدم في محرك Unity تندرج تحت القائمة: Game Object > UI لنبدأ أولا بصورة خلفية للشاشة الرئيسية. هذه الخلفية تتم إضافتها عن طريق لوح: Game Object > UI > Panel بعد أن تضيف هذا اللوح للمشهد، ستلاحظ أن Unity قد قام بإضافته كإبن لكائن جديد اسمه Canvas، وقام أيضا بإضافة كائن آخر اسمه EventSystem. هذان الكائنان جزء من الآلية المتبعة لبناء واجهة المستخدم، حيث يعتبر Canvas الكائن الجذري لجميع كائنات الواجهة، ويقوم EventSystem بتسهيل عملية استقبال مدخلات اللاعب على عناصر الواجهة بغض النظر عن نوع أداة التحكم التي يستخدمها. ما يهمنا الآن هو الكائن الذي أضفناه نحن وهو Panel والذي ستلاحظ أنه تلقائيا قد ملأ إطار الواجهة كاملا. وأنه يحتوي على مكوّن من نوع Image وهو عبارة عن صورة يتم عرضها على الواجهة. قبل الخوض في خطوات بناء الواجهة لنتعرف معا على الآلية المستخدمة للإبقاء على عناصرها في أماكنها وأحجامها الصحيحة بغض النظر عن قياس الشاشة التي تعرض اللعبة عليها. المكوّن المسؤول عن هذه الآلية هو RectTransform والذي ستراه في كائنات واجهة المستخدم بدلا من المكوّن Transform الموجود في كائنات المشهد الأخرى. هذا المكوّن موضح في الصورة التالية: أكثر ما يعنينا في هذا المكوّن هو متغيرا الحجم Scale على المحورين x و y، إضافة إلى نوع وموقع نقطة الارتكاز لكل عنصر من عناصر واجهة المستخدم. لنتحدث بقليل من التفصيل عن طريقة الارتكاز التي عن طريقها يتم تحديد موقع العنصر على الشاشة ولنطّلع أولا على الصورة التالية التي تمثل الخيارات المتوفرة للارتكاز: أول ما يمكن ملاحظته هو إمكانية تحديد نوع الارتكاز بشكل مختلف أفقيا وعموديا، حيث يمكننا استخدام التمدد stretch والذي يجعل العنصر مربوطا من زواياه الأربع وبالتالي يجب أن تبقى هذه الزوايا دائما في مواقعها بغض النظر عن حجم الشاشة. أما الخيارات الأخرى مثل left ،center ،right أفقيا أو top ،middle ،bottom عموديا، فهي تحدد نقطة ارتكاز العنصر بالنسبة للكائن الأب، فإذا حددت مثلا نقطة الارتكاز بأنها middle center، فإن العنصر سيحافظ على مسافة ثابتة من وسط الكائن الأب بغض النظر عن حجم هذا الأخير وحجم الشاشة. سنستخدم هذا الخيار عند إضافة أزرار القائمة الرئيسية كأبناء لكائن اللوح، وبذلك نضمن وجودها دائما في وسط الشاشة. بالعودة لخلفية القائمة، قم بسحب إحدى صور الخلفية المتوفرة إلى الخانة Source Image لتظهر هذه الصورة في خلفية الشاشة الرئيسية. افتراضيا سيقوم Unity بجعل كائن اللوح شفافا نسبيا مما يظهره بشكل معتم وهذا ما لا نريده. لنقم بإزالة هذه الشفافية عن طريق فتح لوح الألوان بالضغط على المستطيل الأبيض في الخانة Color، ومن ثم تحريك منزلق الشفافية A إلى أقصى اليمين كما ترى هنا: بعد ذلك قم بتغيير اسم الكائن من Panel إلى MainMenu. سنضيف لهذا اللوح ثلاثة أبناء: الأول هو ظل العنوان GameTitleShadow.الثاني والثالث هما الزران "ابدأ اللعب" NewGame و "خروج" Exit.لنبدأ أولا مع ظل العنوان: لماذا أضفت ظل العنوان قبل العنوان نفسه؟ السبب هو أن ترتيب تصيير عناصر واجهة المستخدم يقدّم تصيير الآباء على الأبناء. لذا سنضيف العنوان نفسه GameTitle كابن لظل العنوان GameTitleShadow. بعد ذلك قم بكتابة عنوان اللعبة "الحيوانات الغاضبة" داخل الخانة Text لكل من الظل والابن، ومن ثم غير ألوانهما للأخضر بحيث يبدو الظل أفتح من العنوان نفسه. وأخيرا قم بتصغير العنوان قليلا حتى يظهر الظل من خلاله. (استخدمت خطا يسمى HACEN PROMOTER LT ويمكن تحميله مجانا من الموقع hacen.net). الشكل التالي يوضح الإعدادات الكاملة لنص العنوان وظله: بالنسبة للنص العربي الذي يظهر مقطعا ومن اليسار لليمين: لا تقلق حيال هذا الأمر فالحل موجود وسهل وسنطبقه بعد قليل. أخيرا سيظهر شكل العنوان في الشاشة كما يلي: علينا بعد ذلك أن نقوم بإضافة كائنين من نوع Button أي أزرار، وسنضيفهما بشكل عمودي أحدهما فوق الآخر في منتصف الشاشة. لكن قبل ذلك لنضف بعض المصادر الجديدة ونقوم بإعدادها، وهذه المصادر هي عبارة عن صور ورموز لواجهة المستخدم تم استخراجها من المجموعتين التاليتين: http://www.kenney.nl/assets/ui-packhttp://www.kenney.nl/assets/game-iconsطبعا لن نحتاج لجميع محتويات المجموعتين، لكن سنكتفي بالعناصر الموضحة في الصورة التالية: لنبدأ أولا بإعداد صور الأزرار الستة: الثلاث الزرقاء والثلاث الحمراء. قم باختيار أحد الأزرار من مستعرض المشروع ومن ثم اضغط على الزر Sprite Editor في نافذة الخصائص. ستظهر لك نافذة تحرير الصورة Sprite Editor والتي تمكنك من تقطيع الصورة مستخدما نظام الأجزاء التسعة. هذا النظام يقوم على تقسيم أي صورة نريد استخدامها في واجهة المستخدم إلى 9 مناطق كما هو موضح في الصورة التالية: الهدف من هذا التقسيم هو جعل الصورة قابلة للتمدد أفقيا وعموديا، بحيث تبقى الزوايا دائما بحجمها الأصلي ويتم شد الأجزاء أفقيا وعموديا دون أن يؤثر ذلك سلبا على المظهر. يمكنك من خلال النافذة المذكورة تحريك خطوط التقسيم الخضراء من أجل عزل الزوايا عن بقية الأجزاء التي ستتم عملية شدها أثناء تغيير حجم عنصر الواجهة. ل احظ أن لدينا 3 أزرار من كل لون: الأول مرتفع والثاني عادي والثالث مضغوط للأسفل. الأزرار التي سنضيفها ستعمل كالآتي. في الحالة الافتراضية سنعرض صورة الزر العادي، وحين تمرير الفأرة على الزر سنعرض صورة الزر المرتفع، وحين الضغط عليه سنعرض صورة الزر المنخفض. يمكنك تحقيق هذا السلوك عن طريق ضبط إعدادات المكوّنين Image و Button في كائن الزر وفق القيم التي تراها في الصورة التالية. تذكر أن نقطة الارتكاز للأزرار وللعنوان هي المنتصف أفقيا وعموديا middle center. لاحظ أننا حددنا الصورة الافتراضية للزر عبر المكوّن Image، حيث تلاحظ أن نوع الصورة هو Sliced أي صورة مقسمة إلى 9 أجزاء كما سبق ورأينا. بعدها قمنا بتغيير نوع الانتقال في الزر Transition إلى Sprite Swap بحيث نقوم بتبديل الصورة حين الانتقال بين الحالات. حددنا هنا صورتين أخريين إحداهما لحالة التحديد أو المرور بالفأرة Highlighted Sprite والثانية لحالة الضغط Pressed Sprite. أخيرا ستلاحظ وجود كان ابن لكل زر وهو عبارة عن نص يعرض الكتابة التي نرغب بظهورها على الزر. بالتالي يمكننا استخدام هذه الكائنات للكتابة والحصول في النهاية على النتيجة التالية: حل مشكلة اللغة العربيةمشكلة اللغة العربية في محرك Unity قديمة وقد عانيت معها منذ أول إصدار استخدمته وهو 3.5، وحتى الإصدار الخامس لم تحل هذه المشكلة من قبل الشركة. إلا أن الحل موجود، حيث قمت قبل عدة سنوات بكتابة بريمج يسمى ArabicText، ومبدأ عمله يقوم على عكس ترتيب الأحرف بحيث تصبح من اليمين لليسار كما يفترض، إضافة إلى استبدال رموز الحروف المتقطعة بالمتصلة حسب موقع الحرف في الكلمة. هذا البريمج موجود في المشروع داخل المجلد Assets\Scripts\UI وكل ما عليك هو إضافته لكل كائن من نوع Text (أي العنوان وظل العنوان وكائنات الأبناء النصيّة للأزرار). عند تشغيل اللعبة سيقوم البريمج بعمله في تصحيح النص كما ترى في الصورة التالية. هذا البريمج مجرد أداة مساعدة لا علاقة لها بموضوع الدرس لذا لن أقوم بشرحه هنا، لكن يمكنك الاطلاع عليه في المجلد المذكور إن كنت ترغب بذلك. التفاعل مع عناصر واجهة المستخدمأصبحت القائمة الرئيسية جاهزة الآن، إلا أن ضغطك على الأزرار لن يكون ذا تأثير حيث لا توجد أية أوامر ترتبط بها بعد. ربط الأوامر بالأزرار يتم عن طريق استدعاء داّلة أو أكثر من بريمج محدد عند الضغط على الزر. من أجل ذلك سنقوم بكتابة بريمج لكل أمر من هذه الأوامر. البريمج الأول وهو الأسهل هو برنامج الخروج من اللعبة ExitGameCommand. أمر الخروج يمكن تنفيذه في القائمة الرئيسية وفي مشهد اللعبة على حد سواء، حيث يقوم في الحالة الأولى بإغلاق تطبيق اللعبة نهائيا وفي الثانية بالعودة للقائمة الرئيسية. هذا البريمج موضح في السرد التالي: using UnityEngine; using System.Collections; public class ExitGameCommand : MonoBehaviour { //يتم استدعاؤها مرة واحدة عند بداية التشغيل void Start () { } //يتم استدعاؤها مرة عند تصيير كل إطار void Update () { } //تخرج من اللعبة public void ExitGame() { if (Application.loadedLevel == 0) { //نحن الآن في القائمة الرئيسية، بالتالي أغلق التطبيق نهائيا Application.Quit(); } else if (Application.loadedLevel == 1) { //نحن في مشهد اللعبة، بالتالي عد للقائمة الرئيسية Application.LoadLevel(0); } } }لاحظ أن البريمج يستخدم أوامر مباشرة يتيحها محرك Unity من أجل تحميل المشاهد حسب أرقامها. حيث نعرف المشهد الحالي عن طريق المتغير Application.loadedLevel ونقوم بتحميل المشهد الذي نريد عن طريق الدّالة ()Application.LoadLevel. تذكر أن مشهد القائمة الرئيسية يحمل الرقم 0 ومشهد اللعبة يحمل الرقم 1. أخيرا يمكنك ملاحظة أن الدّالة ()Application.Quit تستخدم لإغلاق التطبيق. الخطوة التالية هي ربط الضغط على الزر "خروج" باستدعاء الدّالة ()ExitGame. الخطوة الأولى هي أن نضيف هذا البريمج إلى المشهد على أي كائن، وليكن كائن الزر نفسه. بعدها نعود للمكوّن Button حيث سنجد في أسفله قائمة بالأوامر التي نريد استدعاءها حين الضغط على هذا الزر وهي مرتبة في القائمة ()OnClick كما في ترى في الصورة التالية. يمكنك إضافة أمر جديد بالضغط على الزر + في أسفل القائمة: عند إضافة أمر جديد للقائمة، عليك تحديد الكائن الذي سينفذ الأمر، ومن ثم تحدد البريمج، وأخيرا تحدد الدّالة داخل هذا البريمج. بما أننا أضفنا البريمج ExitGameCommand على كائن الزر نفسه، علينا أن نسحب الزر "خروج" Exit من الهرمية إلى داخل الخانة الخاصّة بالكائن، ومن ثم نفتح قائمة الأوامر والتي تكون قيمتها الافتراضية No Function حيث نختار البريمج ExitGameCommand ومن ثم الدّالة ()ExitGame كما في الصورة التالية: بالتالي عند الضغط على الزر "خروج" فإن التطبيق سيتم إيقاف تشغيله (ملاحظة: لا يعمل الأمر ()Application.Quit من داخل محرر المحرك، عليك بناء التطبيق وتشغيله منفردا لتجربته). لننتقل الآن للزر الثاني وهو زر بداية اللعب. عند الضغط عليه سيعرض نافذة جديدة تحتوي على المراحل الموجودة بحيث يتسنى للاعب اختيار مرحلة منها. من أجل ذلك علينا أولا بناء نافذة مستقلة تحتوي على أزرار المراحل. مبدئيا سنكتفي بزرين يحملان الأرقام 1 و 2 حيث سيكون لدينا مرحلتان فقط في هذا المثال. إضافة لذلك سيكون هناك زر ثالث يحمل الرمز X يقوم بإغلاق النافذة والعودة للقائمة الرئيسية مرة أخرى. لعمل النافذة المذكورة قم بإضافة لوح جديد Panel واحرص على أن يكون ترتيبه بين أبناء الكائن Canvas تحت اللوح الأول الخاص بالقائمة الرئيسية MainMenu. هذا الترتيب مهم حيث أن الكائنات في أسفل الهرمية تظهر على الشاشة أمام الكائنات التي في الأعلى، وهذا ما نريده: أن تظهر هذه النافذة حين عرضها أمام القائمة الرئيسية. قم بتغيير لون اللوح للأسود مع الإبقاء على الشفافية، ومن ثم غير اسمه إلى LevelSelector وحجمه على المحورين الأفقي والعمودي إلى 0.9. هذا سيجعل شكله يبدو كالآتي (لاحظ أيضا ترتيب العناصر في الهرمية على اليسار). ما يلزمنا الآن هو ترتيب الأزرار داخل هذه النافذة على شكل جدول مكون من صفوف وأعمدة، بحيث تكون هذه الأزرار متساوية في الحجم وبينها مسافات ثابتة. لحسن الحظ فإن Unity يسهل علينا هذه المهمة عن طريق توفير المكوّن GridLayoutGroup. هذا المكوّن يقوم بترتيب العناصر داخل اللوح بالطريقة التي ذكرتها للتو، وهو يحتوي على عدة متغيرات كما ترى في هذه الصورة. انتبه لأن إضافة مكوّن كهذا يلغي تأثير طريقة الارتكاز التي تحدثنا عنها سابقا، حيث يصبح المكوّن هو المسؤول عن المواقع والأحجام الخاصة بالعناصر التي يحتويها. المتغيرات الأربع تحت البند Padding تحدد حجم الهوامش من الجهات الأربعة بالبكسل، وهنا اخترت القيمة 32. بعدها نقوم بتحديد حجم كل عنصر أفقيا وعموديا عن طريق المتغير Cell Size وأخيرا نحدد المسافات بين العناصر وهي هنا 16. باقي المتغيرات يمكن تركها على قيمها الافتراضية. بعد هذا علينا أن نصنع قالبا لزر المرحلة بحيث يمكننا إضافته عدة مرات مع تغيير المرحلة التي سيتم تحميلها بتغير الزر. الزر المذكور شبيه بالأزرار التي أضفناها حتى الآن من حيث آلية العرض بتغيير الصور، لكن الذي سيتغير هو النص حيث سيحمل كل زر رقم المرحلة (1، 2، 3، …) إضافة لتغيير البريمج الذي سيستقبل الأمر. بعد صنع قالب الزر حسب الوصف السابق، سنضيف عليه البريمج LevelButton، وهو موضح في السرد التالي: using UnityEngine; using System.Collections; public class LevelButton : MonoBehaviour { //تستدعى مرة واحدة عند بداية التشغيل void Start () { } //تستدعى مرة عند تصيير كل إطار void Update () { } //تقوم ببدء اللعبة مستخدمة رقم المرحلة المزود public void StartLevel(int levelIndex) { //SelectedLevelخزن رقم المرحلة في الإعدادات في الخانة PlayerPrefs.SetInt("SelectedLevel", levelIndex); //قم بتحميل المشهد رقم 1 وهو مشهد اللعبة Application.LoadLevel(1); } } يعمل هذا البريمج عند استدعاء الدّالة ()StartLevel على تحميل مشهد اللعبة (المشهد رقم 1)، لكن قبل ذلك يقوم بتخزين رقم المرحلة التي يجب تحميلها في إعدادات اللاعب. هذه الإعدادات يتم تخزينها على القرص الصلب أي أنها دائمة وليست في الذاكرة. بالتالي فإن القيمة التي تخزن فيها ستبقى محفوظة حين الانتقال بين مشهدي القائمة واللعبة. يمكن الوصول لإعدادات اللاعب عبر PlayerPrefs والتي تحتوي على دوالّ لتخزين وقراءة بيانات بأنواع مختلفة. هنا مثلا استخدمنا الدّالة ()SetInt والتي تقوم بتخزين عدد صحيح، واخترنا اسما للقيمة التي قمنا بتخزينها وهي SelectedLevel. سنرى لاحقا كيف يمكننا قراءة هذه القيمة وتحميل المرحلة بناء عليها. ما علينا فعله الآن هو إضافة عنصر جديد للقائمة ()OnClick ومن ثم سحب الزر نفسه لخانة الكائن تماما كما فعلنا مع زر الخروج من اللعبة. بعدها سنختار البريمج LevelButton من القائمة ومن ثم نختار الدّالة ()StartLevel. الشيء الجديد الذي ستلاحظه هذه المرة هو وجود خانة لإدخال قيمة المتغير levelIndex، حيث أن هذه الدّالة تأخذ متغيرا بخلاف ()ExitGame التي تعاملنا معها في زر الخروج من اللعبة. لاحظ الصورة التالية: سنقوم بعمل زرين من هذا القالب. أحدهما سيحمل النص 1 والقيمة 0 للدّالة ()StartLevel، والآخر سيحمل النص 2 والقيمة 1 للدّالة ()StartLevel. هذان الزران سنقوم بإضافتهما كأبناء للكائن LevelSelector. بعد إضافة الأزرار بالترتيب الصحيح سيتكفل المكوّن Grid Layout Group بوضع كل منهما في الموقع الصحيح وبالحجم الصحيح. قبل الانتقال للحديث عن الزر الثالث وهو زر إغلاق النافذة، لنقم بكتابة البريمج الذي سيحول هذا اللوح إلى نافذة يمكن فتحها وإغلاقها. هذا البريمج هو UIDialog وهو موضح في السرد التالي: using UnityEngine; using System.Collections; public class UIDialog : MonoBehaviour { //قياس النافذة حين تكون مفتوحة public Vector2 maxSize = Vector2.one; //هل تتمدد النافذة حاليا؟ private bool expanding = false; //هل تتقلص النافذة حاليا؟ private bool shrinking = false; //تستدعى مرة واحدة عند بداية التشغيل void Start () { //أخف النافذة مبدئيا transform.localScale = Vector2.zero; } //تستدعى مرة عند تصيير كل إطار void Update () { //اجلب القياس الحالي للنافذة Vector2 scale = transform.localScale; //احسب القياس الجديد بناء على حالة النافذة if (expanding) { scale = Vector2.Lerp(scale, maxSize, Time.deltaTime * 10); //تحقق من الوصول للمنطقة الميتة if (Vector2.Distance(scale, maxSize) < 0.01f) { scale = maxSize; expanding = false; } } else if (shrinking) { scale = Vector2.Lerp(scale, Vector2.zero, Time.deltaTime * 10); //تحقق من الوصول للمنطقة الميتة if (Vector2.Distance(scale, Vector2.zero) < 0.01f) { scale = Vector2.zero; shrinking = false; } } //قم بتغيير القياس للقيمة الجديدة transform.localScale = scale; } //تقوم بفتح النافذة public void Show() { expanding = true; shrinking = false; } //تقوم بإغلاق النافذة public void Hide() { shrinking = true; expanding = false; } } يعتمد مبدأ عمل هذا البريمج على قيمة المتغيرين expanding و shrinking، حيث يحددان ما إذا كانت النافذة تتقلص إلى أن تصل لمرحلة الإغلاق أو إن كانت تتمدد حتى تصل لمرحلة الفتح. حين فتح النافذة يتم إيصال قياسها لقيمة الحد الأعلى المحددة في maxSize، أما حين الإغلاق يجب أن تصل قيمة القياس لصفر حتى تختفي النافذة تماما. لاحظ أن تمدد وتقلص النافذة يتم بشكل سلس عبر ()Vector2.Lerp والتي تستخدم الاستيفاء الذي شرحناه سابقا. افتراضيا يتم ضبط قياس النافذة على Vector2.zero مما يؤدي لأن تبدأ مختفية (مغلقة)، وسيتم إظهارها (فتحها) حين استدعاء الدّالة ()Show، كما يمكن استدعاء ()Hide لإخفائها (أو إغلاقها) ثانية. بعد إضافة هذا البريمج للكائن LevelSelector ستلاحظ أنه يختفي تلقائيا عند تشغيل اللعبة. بقي علينا أن نظهره حين يضغط اللاعب على "ابدأ اللعب" ونخفيه حين يضغط اللاعب على الزر X الذي سنضيفه لهذه النافذة. لنبدأ بعمل زر إغلاق النافذة مستخدمين الصور الثلاث للزر الأحمر. بعد إضافة هذا الزر سنقوم بحذف مكوّن النص من الكائن الإبن Text ونضيف بدلا منه كائن صورة Image كما هو موضح في الصورة التالية: بعدها سنقوم بسحب الصورة cross والموجودة في مجموعة رسوم واجهة المستخدم في المجلد Assets\Kenney.nl\UI Pack إلى الخانة Source Image مما يجعل الشكل النهائي لنافذة اختيار المرحلة يبدو كالتالي: بالنسبة لزر الإغلاق الأحمر سيكون الكائن الهدف لتنفيذ الأمر هو النافذة LevelSelector بالتالي سنسحبها لعنصر جديد نضيفه للقائمة ()OnClick، ومن ثم سنقوم بتحديد البريمج UIDialog والدّالة ()Hide كأمر يتم تنفيذه حين الضغط على هذا الزر. بعدها سنعود للزر الأول في القائمة الرئيسية وهو "ابدأ اللعب" ونضيف له النافذة LevelSelector كهدف للأمر والبريمج UIDialog أيضا، إلا أن الدّالة هذه المرة ستكون ()Show حيث أن الضغط على هذا الزر سيظهر النافذة. الشكل النهائي للقائمة ()OnClick في هذين الزرين سيبدو كالآتي: لو قمت بتشغيل اللعبة الآن ستلاحظ أن الضغط على "ابدأ اللعب" سيظهر لك نافذة اختيار المراحل، ومن هناك يمكنك الضغط على الزر 1 أو 2 لتحميل المرحلة التي تريدها. بطبيعة الحال لا توجد أي مراحل حاليا، لذا سيأخذك الضغط على هذه الأزرار إلى مشهد اللعبة الفارغ. ما نريد عمله الآن هو إضافة زر في مشهد اللعبة يمكّن اللاعب من العودة للقائمة الرئيسية. كل ما علينا فعله هو إضافة زر صغير في أعلى يمين الشاشة يحمل إشارة X ، وعند الضغط عليه سيظهر للاعب نافذة صغيرة يسأله من خلالها إن كان يريد أن يعود للقائمة الرئيسية. بالنظر لموقع الزر المفترض، علينا مراعاة أن تكون نقطة ارتكازه في أعلى اليمين، وذلك حتى يبقى على مسافة ثابتة من أعلى يمين الشاشة بغض النظر عن حجمها. هذا الزر لا يختلف عن التي أنشأناها سابقا سوى أنه أصغر حجما. سنعود لاحقا لإضافة الأمر الخاص بهذا الزر. الصورة التالية توضح موقعه في الشاشة، ويمكنك ملاحظة نقطة ارتكازه في أعلى اليمين: بعد إضافة هذا الزر سنضيف نافذة حوار جديدة كالتي أضفناها في القائمة الرئيسية لاختيار المراحل، إلا أنها ستكون أصغر حجما وتحتوي على سؤال لتأكيد العودة للقائمة الرئيسية، إضافة لزرين للاختيار بين نعم و لا. الصورة أدناه تمثل الشكل والحجم المفترضين لنافذة كهذه. (لاحظ الهرمية في يسار الصورة لترى مم تتكون هذه النافذة): الزر الذي يحمل علامة ✓ سيعود باللاعب للقائمة الرئيسية، بينما الآخر سيقوم بإغلاق نافذة الحوار هذه مع البقاء في مشهد اللعبة• من أجل العودة للقائمة الرئيسية سنحتاج مرة أخرى للبريمج ExitGameCommand ولكننا سنضيفه هذه المرة على الكائن الجذري للواجهة Canvas حيث سنحتاج لاستدعائه من أكثر من نافذة كما سنرى. بعد إضافة البريمج ستكون الدّالة ()ExitGame المعرفة فيه هي الأمر الذي سينفذ حين الضغط على الزر ✓، بينما سيكون Canvas هو الكائن الهدف حيث أنه من يحمل هذا البريمج. بالتالي سيكون شكل القائمة ()OnClick كما يلي: تذكر أن الدّالة ()ExitGame تعود للقائمة الرئيسية في حال تم استدعاؤها من مشهد اللعبة. بعد ذلك علينا أن نضيف لهذه النافذة الصغيرة البريمج UIDialog بحيث تبدأ مختفية وتظهر حين استدعاء ()Show. بطبيعة الحال سيكون زر الخروج الموجود أعلى يمين الشاشة هو من يستدعي الدّالة ()Show عند الضغط عليه، بينما سيكون زر الإغلاق X الموجود على النافذة نفسها هو من يستدعي لها الدّالة ()Hide. أعتقد أنه إلى هنا أصبحت فكرة ربط الضغط على الزر بكائن معين واستدعاء دالّة من بريمج عليه واضحة، لذا لن أعاود شرحها بالصور وسأكتفي بذكر الارتباطات اللازمة. بهذا تكون القائمة الرئيسية للعبة جاهزة، ويمكننا الانتقال بينها وبين مشهد اللعبة. بقي علينا أن نجهز المراحل وما يتعلق بحالة اللعبة حتى تصبح لعبتنا مكتملة ويمكننا لعبها. إضافة المراحل والتنقل بينهابعد أن أصبحت عناصر بناء المرحلة مكتملة لدينا إضافة للشاشة الرئيسية وبعض عناصر واجهة المستخدم، سنقوم الآن بصنع مرحلتين لتجربة تتابع المراحل إضافة للتنقل بينها وبين الشاشة الرئيسية. يمكنك أن تستخدم أي مقذوفات ترغب بها وأي وحدات بنائية وخصوم. المهم هو أن تتبع بناء هرميا محددا حتى تكون جميع المراحل متوافقة في الشكل. الشكل التالي يوضح مثالا على مرحلة صغيرة ومما تتكون هرمية المرحلة. لاحظ أن المرحلة تتكون من 3 عناصر: القاذف، والعناصر البنائية والوحوش والتي تندرج تحت الكائن الفارغ Elements، وأخيرا المقذوفات والتي تندرج تحت الكائن الفارغ Projectiles. لاحظ أيضا أن جميع العناصر على مستوى أفقي واحد وهو المستوى الذي ستظهر فيه الأرضية. عند بناء المرحلة، راعي أن يكون الكائن الفارغ الأب للمرحلة (Level1 في الصورة السابقة) في منتصف المشهد، وأن يكون أسفل المرحلة على مستوى الأرضية التي ستظهر. بعد بناء المرحلة يجب أن نحولها كاملة إلى قالب واحد كبير، لكن قبل ذلك علينا إضافة بريمج بسيط لكائن المرحلة الجذري. هذا البريمج مهمته تحديد رقم الخلفية التي ستظهر حين تحميل المرحلة ويسمى GameLevel وهو موضح في السرد التالي: using UnityEngine; using System.Collections; public class GameLevel : MonoBehaviour { //صورة الخلفية التي ستظهر عند تحميل المرحلة public int backgroundIndex; //تستدعى مرة واحدة عند بداية التشغيل void Start () { } //يتم استدعاؤها مرة عند تصيير كل إطار void Update () { } }المهم في هذا البريمج هو أن نعرف أي صورة خلفية يجب أن نختار عند تحميل المرحلة، عدا عن ذلك فمحتويات المرحلة كفيلة بالتحكم بحالة اللعبة كما سنرى بعد قليل. بعد تجهيز المراحل على شكل قوالب، سنقوم بعمل بريمج جديد لتخزين هذه القوالب ومن ثم إنشائها حسب الحاجة، أي حسب المرحلة التي يتم تحميلها. هذا البريمج يسمى GameLevelLoader وسنقوم بإضافته للكائن الجذري في المشهد. السرد التالي يوضح هذا البريمج: using UnityEngine; using System.Collections; public class GameLevelLoader : MonoBehaviour { //تقوم بتخزين القوالب الخاصة بجميع المراحل public GameLevel[] allLevels; //العنصر الخاص بالمرحلة المحملة حاليا private int currentLevel = -1; //مرجع لبريمج التحكم بالخلفية private BackgroundManager bgManager; //مرجع لبريمج أمر الخروج من اللعبة ExitGameCommand egc; //يتم استدعاؤها مرة عند بداية التشغيل void Start () { bgManager = GetComponent<BackgroundManager>(); egc = FindObjectOfType<ExitGameCommand>(); } //يتم استدعاءها مرة عند تصيير كل إطار لكن في وقت متأخر void LateUpdate () { if (currentLevel == -1) { //قم بتحميل المرحلة التي تم اختيارها من القائمة الرئيسية //إذا لم تكن هناك أي مرحلة، قم تلقائيا بتحميل المرحلة الأولى int selectedLevel = PlayerPrefs.GetInt("SelectedLevel", 0); LoadLevel(selectedLevel); } } //تقوم بتحميل المرحلة المحددة public void LoadLevel(int index) { //قم بالتأكد من وجود المرحلة المطلوبة في المصفوفة //إن لم تكن موجودة فعد للقائمة الرئيسية if (index < 0 || index >= allLevels.Length) { egc.ExitGame(); return; } //قم بالبحث عن المرحلة المحملة حاليا وتدميرها إن وجدت GameLevel current = FindObjectOfType<GameLevel>(); if (current != null) { Destroy(current.gameObject); } //قم بإنشاء المرحلة الجديدة مستخدما قالبها GameObject newLevelObject = (GameObject)Instantiate(allLevels[index].gameObject); GameLevel newLevelScript = newLevelObject.GetComponent<GameLevel>(); //قم بتحديد الكائن الأب والموقع للقائمة الجديدة newLevelObject.transform.parent = transform; newLevelObject.transform.position = Vector2.zero; //قم بتغيير رقم العنصر الخاص بالمرحلة الحالية currentLevel = index; //قم بتغيير الخلفية للصورة المحددة في المرحلة الجديدة bgManager.ChangeBackground(newLevelScript.backgroundIndex); //قم بإبلاغ البريمجات الأخرى بان هناك مرحلة جديدة تم تحميلها للتو SendMessage("NewLevelLoaded"); } //تقوم بإعادة لعب المرحلة الحالية public void RestartCurrentLevel() { if (currentLevel != -1) { LoadLevel(currentLevel); } } //تقوم بتحميل المرحلة التالية في المصفوفة public void LoadNextLevel() { LoadLevel(currentLevel + 1); } }هذا البريمج يحتوي على متغير عام واحد فقط وهو المصفوفة allLevels التي تحتوي على قوالب جميع مراحل اللعبة. لاحظ أن تحميل المرحلة يتم تأخيره باستخدام الدّالة ()LateUpdate وذلك حتى نضمن أن جميع البريمجات الأخرى قد بدأت العمل ويمكننا الاعتماد عليها مثل البريمج BackgroundManager والذي سنستخدمه لتغيير صورة الخلفية للصورة المحددة في البريمج GameLevel الخاص بالمرحلة الحالية. يبحث البريمج مبدئيا عن متغير مخزن في إعدادات اللاعب ويحمل الاسم SelectedLevel. كما تذكر فإن هذا المتغير يفترض أن يتم تخزينه من قبل البريمج LevelButton والخاص بأزرار المراحل في القائمة الرئيسية. إذا لم يوجد هذا المتغير يتم إرجاع القيمة الافتراضية وهي 0 بالتالي يتم تحميل المرحلة الأولى في المصفوفة. الدّالة ()LoadLevel هي الأساسية في هذا البريمج حيث نعطيها رقم المرحلة التي نرغب بتحميلها من المصفوفة، فإذا لم يكن رقم المرحلة المحددة صالحا ستعود للقائمة الرئيسية للعبة. أما إذا كان الرقم صحيحا فإنها تتأكد من عدم وجود مرحلة محملة حاليا عن طريق البحث عن بريمج من نوع GameLevel، إذا وجدت هذا البريمج فإنها تقوم بتدمير الكائن الذي يحمله، وبما أن الكائن هو جذر عناصر المرحلة جميعها، سيتم تدمير جميع هذه العناصر أيضا، مما يجعل المشهد فارغا وجاهزا لاستقبال المرحلة الجديدة. هذه المرحلة يتم إنشاؤها عن طريق استخراج القالب الموجود في الموقع المحدد index ومن ثم بناء كائن منه. هذا الكائن تتم إضافته كابن للكائن الجذري لمشهد اللعبة كما يتم وضعه في منتصف المشهد. بعدها يتم تحديث قيمة currentLevel إلى المرحلة الجديدة وتغيير الخلفية باستخدام المتغير backgroundIndex الخاص ببريمج المرحلة الجديدة. أخيرا تقوم الدّالة بإرسال الرسالة NewLevelLoaded حتى تخبر البريمجات الأخرى بأن المرحلة الجديدة تم تحميلها. إضافة لذلك لدينا الدّالتان ()RestartCurrentLevel والتي تقوم بإعادة تحميل المرحلة الحالية، و ()LoadNextLevel والتي تقوم بتحميل المرحلة التالية في الترتيب في المصفوفة. حالة اللعبة وشروط الفوز والخسارةبعد أن أصبحت جميع محتويات اللعبة مكتملة بما فيها المراحل، بقي علينا أن نضيف شروط الفوز والخسارة وما يترتب عليها من تغيير على حالة اللعبة. في ألعاب من هذا النوع يفوز اللاعب إذا قام بتدمير جميع الخصوم وهي الوحوش في لعبتنا هذه، ويخسر إذا استنفد جميع ما لديه من مقذوفات دون تدمير الخصوم. تذكر أننا سابقا أضفنا البريمج Enemy لكائنات الوحوش والذي يرسل للكائن الجذري الرسالة EnemyDestroyed حين يتم تدمير الكائن، كما أن بريمج المقلاع Launcher يقوم بإرسال الرسالة ProjectilesConsumed عندما يتم إطلاق كافة المقذوفات التي كانت بحوزة اللاعب. ما يتوجب علينا هو كتابة بريمج يستقبل هاتين الرسالتين وبناء عليهما يقوم بفحص حالة اللعبة والتأكد من فوز أو خسارة اللاعب. هذا البريمج هو GameStateManager وهو موضح في السرد التالي: using UnityEngine; using System.Collections; public class GameStateManager : MonoBehaviour { //متغير لمعرفة ما إذا فاز اللاعب بالمرحلة private bool playerWon; //تستدعى مرة واحدة عند بداية التشغيل void Start () { playerWon = false; } //تستدعى مرة عند تصيير كل إطار void Update () { } //والتي ترسلها EnemyDestroyed تستقبل الرسالة //كائنات الوحوش عند تدميرها void EnemyDestroyed() { //قم بعد الوحوش المتبقية في المشهد Enemy[] enemies = FindObjectsOfType<Enemy>(); if (enemies.Length <= 1) { //تم تدمير جميع الوحوش، أي فاز اللاعب بالمرحلة SendMessage("PlayerWon"); playerWon = true; } } //تقوم باستقبال رسالة استنفاد اللاعب //لجميع المقذوفات التي كانت بحوزته void ProjectilesConsumed() { if (!playerWon) { SendMessage("PlayerLost"); } } //التي تعني NewLevelLoaded تستقبل الرسالة //false إلى playerWon أنه تم تحميل مرحلة جديدة، وبناء عليها تعيد void NewLevelLoaded() { playerWon = false; } }في كل مرة يتم فيها تدمير أحد الوحوش يستقبل هذا البريمج الرسالة EnemyDestroyed ومن ثم يقوم بعد الوحوش المتبقية في المشهد عن طريق البحث عن البريمج Enemy. لاحظ أن وجود بريمج واحد في المشهد يعني أن اللاعب قد فاز فكيف هذا؟ الجواب هو أن تدمير الكائن لا يتم مباشرة عند استدعاء ()Destroy، وإنما يتم تأخيره حتى نهاية الإطار الحالي. لذا فمن المحتمل أن تصل الرسالة ويتم بعدها البحث عن بريمج من نوع Enemy وإيجاده. فإن كان العدد واحدا فهذا يعني أنه الأخير المتبقي في المشهد واستقبال الرسالة يعني أنه تم تدميره. لذا نعرف هنا بأن اللاعب قد فاز في المرحلة ويتم إرسال الرسالة PlayerWon وتغيير قيمة playerWon إلى true. أما استقبال الرسالة ProjectilesConsumed فيعني أن آخر مقذوف أطلقه اللاعب قد انقضت مدة بقائه وتم حذفه من المشهد، وأن القاذف لم يجد أي مقذوفات أخرى. حينها تقوم الدّالة ()ProjectilesConsumed باستقبال الرسالة ومن ثم التأكد من أن اللاعب لم يفز باللعبة حتى الآن – أي لم يدمر جميع الوحوش – وفي هذه الحالة تحكم بخسارة اللاعب عن طريق إرسال الرسالة PlayerLost. السؤال الآن هو ماذا سيحدث عندما يتم إرسال PlayerWon أو PlayerLost؟ الجواب هو أن كل رسالة ستقوم بإظهار نافذة حوار مختلفة. ففي حال فوز اللاعب ستظهر له نافذة تخيره بين إعادة اللعب وبين التقدم للمرحلة التالية واسمها WinDialog، وفي حالة الخسارة تظهر نافذة أخرى تخيره بين إعادة المرحلة والخروج للقائمة الرئيسية واسمها LoseDialog. هاتان النافذتان ستكونان كالنوافذ السابقة عبارة عن كائنات Panel مضاف عليها البريمج UIDialog وتحتوي كل منهما على نص وزرين تماما كنافذة تأكيد العودة للقائمة الرئيسية. بداية لنقم ببناء هاتين النافذتين وتحديد الوظائف الخاصة بأزرارها. لنبدأ مع نافذة الفوز والتي ستبدو بالشكل التالي: المشترك في الزرين الموجودين على النافذة هو أن الهدف لأوامرهما هو الكائن الجذري لمشهد اللعبة SceneRoot وتحديدا بريمج تحميل المراحل GameLevelLoader. أما الفرق فهو أن الزر الأيمن سيقوم باستدعاء الدّالة ()LoadNextLevel عند الضغط عليه مما ينقل اللاعب للمرحلة التالية، بينما الزر الأيسر يستدعي عند الضغط عليه الدّالة ()RestartCurrentLevel مما يؤدي لإعادة تحميل المرحلة الحالية. علاوة على ذلك، يجب أن يقوم كلا هذين الزرين أيضا بإخفاء النافذة حتى يتمكن اللاعب من متابعة اللعب سواء في المرحلة التالية أو الحالية. من أجل ذلك يجب أن نضيف لكل منهما هدفا آخر وهو النافذة نفسها، حيث سيقوم كلاهما باستدعاء الدّالة ()Hide من البريمج UIDialog. لاحظ أن ()OnClick هي عبارة عن قائمة كما ذكرنا سابقا، بالتالي يمكنها أن تستدعي أكثر من أمر من أكثر من كائن وبريمج كما هو مبين في الصورة التالية: النافذة الأخرى وهي التي تظهر في حال الخسارة تبدو بهذا الشكل: الزر الأيمن وهو زر العودة للقائمة الرئيسية سيكون الكائن الهدف بالنسبة له هو الكائن الجذري للواجهة Canvas وتحديدا البريمج ExitGameCommand والدّالة ()ExitGame. أما الزر الأيسر فتماما كما في نافذة الفوز يستدعي الدّالة ()RestartCurrentLevel ويقوم أيضا باستدعاء ()Hide من البريمج UIDialog الموجود على كائن نافذة الخسارة LoseDialog. بقي علينا الآن أن نربط بين الرسائل التي يرسلها GameStateManager وبين ظهور هذه النوافذ. المشكلة التي تواجهنا هنا هي أن النوافذ تقع تحت كائن جذري مختلف عن الكائن الجذري للمشهد، وكل منهما ذو وظيفة محددة ولا يجب أن تتداخل هذه الوظائف كثيرا. من أجل ذلك سنقوم بكتابة بريمج يعمل على استقبال الرسائل من GameStateManager ومن ثم استدعاء دوال من بريمج آخر سنضيفه على Canvas بحيث يشكل هذان البريمجان معا جسر التواصل بين واجهة المستخدم ومنطق اللعبة. لنبدأ مع البريمج الأول الذي سنضيفه على جذر واجهة المستخدم Canvas وهو GameStateDialogs الموضح في السرد التالي: using UnityEngine; using System.Collections; public class GameStateDialogs : MonoBehaviour { //متغير لتخزين نافذة فوز اللاعب public UIDialog winDialog; //متغير لتخزين نافذة خسارة اللاعب public UIDialog loseDialog; //تستدعى مرة واحدة عن بداية التشغيل void Start () { } //تستدعى مرة عند تصيير كل إطار void Update () { } //تعرض نافذة فوز اللاعب public void ShowWinDialog() { winDialog.Show(); } //تعرض نافذة خسارة اللاعب public void ShowLoseDialog() { loseDialog.Show(); } } كل ما يفعله هذا البريمج البسيط هو تخزين نافذتي الفوز والخسارة في مراجع ومن ثم عرضها عند استدعاء الدّالة ()ShowWindDialog أو الدّالة ()ShowLoseDialog. بعد إضافة هذا البريمج للجذر Canvas عليك أن تسحب كلا من نافذة الفوز ونافذة الخسارة لخانة المتغير المناسب لها كما ترى في الصورة التالية: لننتقل الآن للطرف الآخر وهو جذر المشهد SceneRoot والذي سنضيف عليه البريمج GameStateReporter الموضح في السرد التالي: using UnityEngine; using System.Collections; public class GameStateReporter : MonoBehaviour { //مرجع لبريمج عرض نافذتي الفوز والخسارة GameStateDialogs gsDialogs; //تستدعى مرة عند بداية التشغيل void Start () { gsDialogs = FindObjectOfType<GameStateDialogs>(); } //تستدعى مرة عند تصيير كل إطار void Update () { } //تستقبل رسالة فوز اللاعب void PlayerWon() { gsDialogs.ShowWinDialog(); } //تستقبل رسالة عرض اللاعب void PlayerLost() { gsDialogs.ShowLoseDialog(); } }كل ما يفعله هذا البريمج هو استقبال رسائل الفوز والخسارة ومن ثم استدعاء الدّالة التي تعرض النافذة المناسبة من البريمج GameStateDialog. بهذا تكون لعبتنا قد اكتملت على جهاز الحاسب ويمكن تشغيلها ولعب المراحل والفوز والخسارة بها. بقي علينا أن ننقلها للهواتف الذكية وشاشات اللمس، وهي خطوة بسيطة نظرا للطريقة المنظمة التي اتبعناها في استقبال المدخلات.
-
قمنا في الدرس السابق من هذه السلسلة بتجهير مشهد اللعبة و بناء الأرضية والخلفية إضافة إلى إعداد الكاميرا. الآن ننتقل إلى المكونات الرئيسية للعبة وهي الوحدات البنائية كالصخور والحجارة وأيضا الخصوم. ملاحظة: يمكن تحميل الملفات المصدرية لكامل هذه السلسلة عبر حساب أكاديمية حسوب على Github، يمكن أيضا تحميل ملف APK لتجريب اللعبة على أجهزة Android. إنشاء الوحدات البنائية للعبةالنوع الثاني من الكائنات التي سنقوم بإنشائها هي الوحدات البنائية التي تتكون منها المراحل. هذه الوحدات عبارة عن أشكال هندسية منها مربعات ومثلثات ومستطيلات ودوائر. هذه الأشكال أيضا تتميز بأنها مصنوعة من مواد مختلفة، فيمكن أن تكون من المعدن أو الصخر أو الخشب أو الزجاج أو المواد المتفجرة، وهذه الأنواع كلها متوفرة بكل الأشكال في مجموعة الصور. هذه المواد تتفاوت في الوزن والصلابة، إضافة لأن المواد المتفجرة حين تتحطم تحدث انفجارا يؤثر في الوحدات المجاورة و الخصوم. لنبدأ أولا بحصر الأشكال التي سنستخدمها، ومن ثم نرى ما الذي يتوفر لدينا من صور لكل من هذه الأشكال، وبالتالي نتعرف على طريقة صناعة القوالب الخاصة بكل شكل. قوالب الأشكال هذه ستشكل العنصر الرئيسي الذي سنبني منه المراحل المختلفة. إذا استعرضت أحد مجلدات الوحدات البنائية ستجد مجموعة كبيرة من الأشكال المختلفة، مثلا الصورة في الأسفل توضح لنا محتويات المجلد Metal elements والذي يحتوي على صور الوحدات البنائية المعدنية: لاحظ أن هناك مجموعة أشكال رئيسية، وكل واحد منها يأتي مرة فارغا من المنتصف ومرة ممتلئا. إضافة إلى ذلك فإن هناك عدة مراحل لتشقق كل وحدة قبل تحطمها نهائيا. اختصارا للوقت سأختار مجموعة صغيرة من هذه الأشكال وهي الموجودة في الصورة التالية: سنتعرف الآن على الخطوات اللازمة لتحويل كل صورة من هذه الصور إلى وحدة بنائية ذات خصائص فيزيائية، إضافة لجعلها قابلة للتحطم عبر تلقيها للضربات أو تصادمها مع غيرها أو سقوطها على الأرض أو تأثرها بالانفجارات. سأبدأ مع المثلثات لأنها حالة خاصة تحتاج لعناية عند بناء القالب الخاص بها، بينما ستكون الأشكال التالية أسهل. لبناء قالب المثلث متساوي الضلعين والذي تراه أيمن الصورة، علينا أولا أن نضيف كائنا فارغا ونسميه Triangle ومن ثم نضيف إليه المكوّن Sprite Renderer ومكوّن التصادم المسمى Polygon Collider 2D والذي سيظهر مبدئيا على شكل خماسي كما توضح الصورة في الأسفل. بعد إضافة مكوّن التصادم يمكننا سحب صورة المثلث إلى داخل الخانة Sprite في المكوّن Sprite Renderer. أنوه هنا إلى أنه من الضروري إضافة مكوّن التصادم قبل تحديد الصورة، وذلك لأن Unity سيحاول تغيير شكل مكوّن التصادم ليطابق شكل الصورة وهو ما لا يتم بدقة. سنقوم الآن بتغيير شكل مكوّن التصادم يدويا وذلك عبر الضغط على الزر Edit Collider. عند تغيير شكل مكوّن التصادم فإنه يمكننا اختيار أي نقطة من النقاط الخمسة وتحريكها، كما يمكننا اختيار نقطة وحذفها بضغط المفتاح Delete. سنحتاج لحذف نقطتين من الخمسة ومن ثم مطابقة النقاط الثلاث المتبقية على زوايا المثلث ليصبح كما ترى في الصورة، وبطبيعة الحال كلما قربت الصورة أثناء مطابقة الزوايا حصلت على نتيجة أدق وأفضل. بعد أن انتهينا من تجهيز شكل مكوّن التصادم يمكننا تثبيته بالضغط مجددا على Edit Collider ليتم حفظ الشكل الجديد. أصبح لدينا الآن مثلث يمكن أن تصطدم به الأجسام الأخرى، إلا أنه نفسه ثابت ولا يتأثر بأي عوامل فيزيائية. لنجعله نشطا فيزيائيا علينا أن نضيف مكوّن الجسم الصلب المسمى Rigid Body 2D، وهو المكوّن المسؤول عن وضع الكائن تحت تحكم المحاكي الفيزيائي الخاص بمحرك Unity، مما يجعله يتأثر بالجاذبية وقوى الدفع وغيرها. هذا المكوّن يحتوي على مجموعة خصائص أذكر هنا أهمها بالنسبة لنا: الكتلة Mass: كلما زادت يصبح تحريك الجسم أصعب لأن إزاحته ستحتاج لقوة أكبر، لكن هذا لا يؤثر على سرعة سقوطه.مقاومة الهواء لحركة الجسم Linear Drag: كلما زادت سيصبح توقف الجسم عن الحركة بعد زوال القوى الخارجية عنه أسرع.مقاومة الهواء للدوران Angular Drag: كلما زادت سيصبح توقف الجسم عن الدوران بعد زوال القوى الخارجية عنه أسرع.معامل الجاذبية Gravity Scale: الجاذبية الافتراضية هي 9.8 كما هو الحال مع الجاذبية الأرضية، وسيتم ضرب هذه القيمة بالمعامل لحساب الجاذبية الخاصة بهذا الكائن تحديدا.سنقوم بتعديل هذه القيم من أجل تمييز أنواع الوحدات البنائية عن بعضها؛ فمثلا الوحدات المعدنية يجب أن تكون أكبر كتلة من مثيلاتها الخشبية وهكذا. بقي الآن أن نجعل هذه الوحدات تتأثر بالصدمات وتتحطم بعد تلقيها عددا معينا منها؛ وهذه الآلية تتم عبر عدة بريمجات سنتعرف عليها. البداية ستكون مع البريمج الذي يحدد مدى قوة تحمل الوحدة البنائية وسنسميه Breakable. مهمة هذا البريمج هي تلقي الصدمات وإنقاص صحة الكائن بناء عليها؛ ومن ثم تدميره حين تصل صحته إلى صفر. السرد التالي يوضح البريمج المذكور. using UnityEngine; using System.Collections; public class Breakable : MonoBehaviour { //مقدار الصحة الأولية للكائن public float initialHealth = 100; //متغير داخلي لمتابعة تغير قيمة صحة الكائن private float health; //تستدعى مرة واحدة عند بداية التشغيل void Start () { health = initialHealth; } //تستدعى مرة عند تصيير كل إطار void Update ( } //تقوم باستقبال الضربات وإنقاص الصحة بناء عليها public void TakeDamage(float amount){ //الصحة منتهية فلا يمكن تلقي المزيد من الضربات if (health <= 0.0f) { return; } if(health <= amount){ //في هذه الحالة تم تدمير الكائن health = 0.0f; SendMessageUpwards("BreakableDestroyed"); Destroy(gameObject); return; } //قم بتقليل قيمة الصحة بمقدار قوة الضربة health -= amount; //أرسل رسالة تخبر بتغير الصحة SendMessage("DamageTaken", health); } } كل ما يفعله هذا البريمج البسيط هو تخزين قيمة الصحة الأولية في متغير خاص داخلي وذلك ليمنع البريمجات الأخرى من تغييرها (تذكر فصل الاهتمامات). يمكن إنقاص الصحة عن طريق إرسال رسالة لهذا البريمج اسمها TakeDamage والتي تأتي مرفقة بمقدار قوة الضربة التي تلقاها الكائن. تقوم الدّالة باستقبال هذه الرسالة وإنقاص قوة الضربة من صحة الكائن، فإذا وصلت صفرا أو أقل من ذلك تقوم بتدميره. ترسل هذه الدّالة رسالتين هما DamageTaken حين تلقي الضربة وترفق معها قيمة الصحة الجديدة بعد إنقاصها. الرسالة الأخرى هي BreakableDestroyed والتي يتم إرسالها عند وصول الصحة إلى صفر أي عند تدمير الكائن. بالعودة إلى مجموعة الصور، لنتذكر ما قلناه عن الصور المشققة والتي تمثل مراحل تحطم الوحدة البنائية. مثلا بالنسبة للمثلث الذي نستخدمه حاليا لدينا الصورة الأصلية (السليمة) إضافة لصورتين تمثلان المرحلة الأولى والثانية للتشقق كما هو موضح في الصورة التالية: ما سنقوم به الآن هو كتابة بريمج آخر يقوم بالربط بين مقدار صحة الكائن والصورة المعروضة. بما أن لدينا ثلاث صور فإن كل واحدة منها ستمثل ثلث الصحة. فإذا كانت الصحة الكاملة 60 مثلا، فإن الصورة الأولى ستبقى ظاهرة طالما أن الصحة بين 60 و40، فإذا قلّت عن 40 ستظهر الصورة الثانية، وإذا قلّت عن 20 ستظهر الثالثة. وبهذه الطريقة نعطي اللاعب فكرة عن قرب تحطم هذه الوحدة. البريمج المسؤول عن هذا الأمر هو BreakableDislay والموضح في السرد التالي: using UnityEngine; using System.Collections; public class BreakableDislay : MonoBehaviour { //مصفوفة لتخزين صور جميع مراحل التدمير //مرتبة من الحالة الأسوأ للحالة الأفضل public Sprite[] destructionStages; //مرجع لبريمج الوحدة البنائية private Breakable brk; //مرجع لمكوّن تصيير الصورة private SpriteRenderer sr; //متغير لتخزين صحة الكائن الأصلية في البداية private float fullHealth; //يتم استدعاؤها مرة واحدة عند بداية التشغيل void Start () { brk = GetComponent<Breakable>(); fullHealth = brk.initialHealth; sr = GetComponent<SpriteRenderer>(); UpdateDisplay (fullHealth); } //يتم استدعاؤها مرة واحدة عند تصيير كل إطار void Update () { } //تقوم باستقبال رسالة تلقي الضربة وتحديث الصورة بناء على قيمة الصحة الجديدة void DamageTaken(float newHealth){ UpdateDisplay(newHealth); } //تقوم بتغيير الصورة المعروضة بناء على قيمة الصحة المزودة void UpdateDisplay(float health){ //قم بتغيير مرحلة التدمير بناء على //قيمة الصحة الجديدة float healthRatio = health / fullHealth; int maxIndex = destructionStages.Length - 1; int matchingIndex = (int)(healthRatio * maxIndex); sr.sprite = destructionStages[matchingIndex]; } } الدّالة الأهم في هذا البريمج هي ()UpdateDisplay، والتي تقوم باختيار صورة مرحلة التشقق الملائمة لمقدار الصحة المتبقية. لنفترض أن لدينا 3 مراحل مختلفة كما هو الحال مع المثلث الذي نعمل عليه. عندما تتغير صحة الكائن نتيجة لتلقيه ضربة معينة، سيقوم بإرسال الرسالة DamageTaken مرفقا معها قيمة الصحة الجديدة. عند استقبال هذه الرسالة من قبل البريمج BreakableDisplay يقوم باستدعاء الدّالة ()UpdateDisplay مباشرة ويزودها بقيمة الصحة الجديدة، عندها تقوم الدّالة بقسمة الصحة الجديدة على الصحة الكاملة التي سبق تخزينها في fullHealth. بقسمة الرقمين نحصل على نسبة الصحة الجديدة من الصحة الكاملة، ومن ثم نقوم بتحويل هذه النسبة إلى عدد صحيح يمثل القيمة الأقرب لها إذا ما قسمناه على عدد العناصر الكلي في مصفوفة صور مراحل التشقق. مثلا إذا كانت الصحة الكاملة هي 100 والجديدة هي 25 وعدد المراحل 3. فإن 0.25 * 2 = 0.5 وبتحويله إلى عدد صحيح (دون جبر الكسر) سيصبح الناتج صفرا أي صورة آخر مرحلة للتشقق قبل التدمير. البريمجان اللذان أضفناهما حتى الآن يعملان على حساب صحة الكائن وعرض صورة درجة التشقق بناء عليها. وكما رأينا فحين تصل صحة الكائن إلى صفر سيتم تدميره وحذفه من المشهد. حذف الكائن من المشهد سيؤدي إلى اختفائه عن الشاشة بشكل فجائي، وهو أمر غير محبذ حيث لا بد أن نعطي اللاعب شعورا أن الوحدة البنائية قد تحطمت إلى قطع صغيرة. لحسن الحظ فإن مجموعة الصور التي استخدمناها تحتوي على صور قطع حطام لجميع أنواع المواد المستخدمة، وما علينا سوى استخدامها بالطريقة الصحيحة. لنبدأ أولا بضبط الخصائص الفيزيائية لهذه القطع ومن ثم نصنع منها قوالب بعد أن تصبح جاهزة للاستخدام. لنبدأ مع القطع الثلاث الخاصة بالمعدن، والتي ستكون أجزاء الحطام لكل من الوحدات المعدنية والمتفجرة. صور هذه الأجزاء موجودة في المجلد Explosive elements وهي موضحة في الصورة التالية: سنصنع قالبا واحدا بحيث يمكننا استخدامه لجميع صور القطع، ومن ثم نقوم بتحديد الصورة التي سنضعها عليه من خلال البريمج الذي سنراه بعد قليل. ما يلزمنا في قالب القطع المتناثرة مكوّنان: الأول هو Sprite Renderer والذي سيعرض الصورة كما نعرف،والمكوّن الآخر هو الجسم الصلب Rigid Body 2D والذي سيمكننا من إضافة قوة دافعة لتحريك القطعة.بعد إضافة هذين المكوّنين أصبح القالب المطلوب جاهزا ويمكننا إضافته للمشروع. الصورة التالية توضح القيم المفترضة للجسم الصلب الخاص بهذا القالب (لاحظ أننا لم نضف مكوّن تصادم، وذلك لأننا لا نريد أن تصطدم هذه القطع بأي شيء بل نريدها أن تتناثر وتسقط فحسب). البريمج الثالث الذي سنضيفه للقالب هو BreakablePieces، ومهمته استقبال الرسالة BreakableDestroyed والتي تخبر بأن الكائن قد تم تدميره، وبناء عليها يقوم بإضافة مجموعة من قطع الحطام للمشهد وتحريكها في اتجاهات متباعدة. ليس هذا فقط، بل إن هذا البريمج سيكون مسؤولا عن إحداث الانفجار في حالة المواد المتفجرة. لنلق نظرة على البريمج أولا في السرد أدناه، ومن ثم نتناول أهم خطواته ببعض الشرح. using UnityEngine; using System.Collections; public class BreakablePieces : MonoBehaviour { //قالب كائن قطع الحطام public GameObject piecePrefab; //الصور المستخدمة لقطع الحطام public Sprite[] piecesSprites; //كم عدد القطع التي سيتم إنشاؤها؟ public int pieceCount; //مقدار تصغير أو تكبير قطع الحطام الناتجة public float scale = 1.0f; //ما هي قوة الانفجار الناتجة من تناثر القطع؟ //القيمة صفر تعني عدم وجود انفجار public float explosionForce = 0.0f; //تستدعى مرة واحدة عند بداية التشغيل void Start () { } //تستدعى مرة واحدة عند تصيير كل إطار void Update () { } //BreakableDestroyed تقوم باستقبال الرسالة public void BreakableDestroyed(){ //قم بجلب مكوّن التصادم Collider2D myCollider; myCollider = GetComponent<Collider2D>(); //قم بإيجاد حدود مكوّن التصادم Bounds myBounds = myCollider.bounds; //قم بإنشاء العدد المطلوب من قطع الحطام for(int i = 0; i < pieceCount; i++){ GameObject piece = (GameObject) Instantiate(piecePrefab); //قم بوضع الصورة التالية على القطعة الجديدة int index = i % piecesSprites.Length; SpriteRenderer sr = piece.GetComponent<SpriteRenderer>(); sr.sprite = piecesSprites[index]; //قم بتوليد موقع عشوائي ضمن حدود مكوّن التصادم float posX = Random.Range(myBounds.min.x, myBounds.max.x); float posY = Random.Range(myBounds.min.y, myBounds.max.y); //قم بوضع قطعة الحطام في الموقع العشوائي piece.transform.position = new Vector2(posX, posY); //قم بتحديد الكائن الأب لقطعة الحطام بحيث //يكون الكائن الأب للوحدة البنائية التي تم تدميرها piece.transform.parent = transform.parent; //قم بتغيير حجم القطعة بالمقدار المطلوب piece.transform.localScale *= scale; //قم بجلب مكوّن الجسم الصلب لقطعة الحطام Rigidbody2D rb = piece.GetComponent<Rigidbody2D>(); //قم بإضافة قوة دفع صغيرة //لتحريك قطعة الحطام بحيث يكون اتجاه القوة من مركز //الجسم الأصلي إلى الموقع العشوائي حيث القطعة Vector2 force = piece.transform.position - transform.position; if(explosionForce == 0.0f){ //لا يوجد انفجار rb.AddForce (force, ForceMode2D.Impulse); } else { //يوجد انفجار rb.AddForce(force * explosionForce, ForceMode2D.Impulse); } //احذف قطعة الحطام من المشهد بعد 5 ثوان Destroy(piece, 5.0f); } if (explosionForce > 0.0f) { //تأثير الانفجار على الأجسام الأخرى //قم بحساب نصف قطر مجال تأثير الانفجار float radius = myBounds.max.magnitude * 2.0f; //قم بإيجاد جميع الوحدات البنائية والأجسام القابلة للتحطيم Breakable[] breakables = FindObjectsOfType<Breakable>(); //قم بإرسال قوة الانفجار كمقدار تدمير للأجسام الواقعة في محيط التأثير foreach (Breakable target in breakables) { //استثن الكائن نفسه الذي نتج عنه الانفجار if (target.gameObject != gameObject) { //قم بحساب المسافة بين الكائن وموقع الانفجار وقارنها بنصف قطر محيط التأثير float dist= Vector2.Distance( transform.position, target.transform.position); if (dist <= radius) { //قم بإضافة قوة الانفجار كمقدار للتدمير target.TakeDamage(explosionForce / dist); //قم بحساب مقدار واتجاه قوة دفع الانفجار Vector2 expDir = target.transform.position - transform.position; expDir = expDir * (explosionForce / radius); //قم بإضافة قوة الانفجار للكائن Rigidbody2D targetRB = target.GetComponent<Rigidbody2D>(); targetRB.AddForce(expDir, ForceMode2D.Impulse); } } } } } } يحتاج هذا البريمج حتى يعمل إلى قالب يستخدمه من أجل إنشاء قطع الحطام المطلوبة، إضافة إلى قائمة بالصور التي يمكنه استخدامها لتمثيل هذه القطع. بطبيعة الحال ستختلف قائمة الصور هذه تبعا لمادة الوحدة البنائية الأصلية؛ فالكائن المعدني سينتج قطعا معدنية والخشبي قطعا خشبية وهكذا. إضافة لذلك يمكننا تحديد عدد القطع التي ستنتج عن تحطيم كل وحدة بنائية وحجم هذه القطع. هذه الأرقام ستمكننا مع ضبط عدد وحجم القطع بما يتناسب مع حجم الشكل الأصلي. أخيرا يمكننا اختيار وجود انفجار من عدمه، ومدى قوة الانفجار إن وجد. هذا يجعل البريمج قابلا للاستخدام مع الوحدات المتفجرة أيضا. كل خطوات هذا البريمج مرتبطة باستقبال الرسالة BreakableDestroyed التي سبق ذكرها. عند استقبال الرسالة يقوم البريمج باستخراج مكوّن التصادم ومن ثم استخراج حدود هذا المكوّن، وهي نفسها حدود الكائن. بعد ذلك يدخل في حلقة تكرارية لإنتاج العدد المطلوب من القطع، حيث يقوم في كل مرة بإنشاء قطعة جديدة واختيار صورة لها بالتسلسل. لاحظ استخدام باقي ناتج القسمة من أجل ضمان العودة من بداية قائمة الصور في حال كان عدد القطع المطلوبة أكبر من الصور المتوفرة. بعدها يتم توليد موقع عشوائي لوضع قطعة الحطام، شرط أن يكون ضمن الحدود التي سبق استخراجها من مكوّن التصادم. بما أنّ الكائن الأصلي سيتم حذفه في نهاية الإطار الحالي، فإنه لا يصلح ليكون أبا لقطع الحطام لأنها ستحذف معه في هذه الحالة. بالتالي نأخذ أب الكائن الأصلي ونحدده كأب لقطع الحطام. كذلك يتم تغيير حجم القطعة بالمقدار المطلوب scale، حيث نحتاج أحيانا لجعلها أكبر أو أصغر من حجمها في الصورة الأصلية. بعد أن تصبح قطعة الحطام جاهزة وفي مكانها الصحيح، سنحتاج لإضافة قوة فيزيائية تحركها بعيدا عن مركز القطعة الأصلية. نقوم أولا باستخراج مكوّن الجسم الصلب Rigid Body 2D وهو المسؤول عن الخصائص الفيزيائية لقطعة الحطام، ومن ثم نحسب المتجه المطلوب لهذه القوة وذلك بطرح موقع قطعة الحطام من موقع الكائن الأصلي. إذا كانت قوة الانفجار تساوي صفرا فلن نحتاج سوى لإضافة هذه القوة للجسم الصلب على شكل قوة اندفاع Impulse. أما في حالة وجود انفجار فالأمر أكثر تعقيدا، حيث يتوجب علينا أولا ضرب القوة الدافعة بمقدار قوة الانفجار مما ينتج عنه اندفاع الحطام بسرعة أكبر ولمسافة أبعد. إضافة لذلك سيكون علينا إحداث تأثير على القطع المجاورة وهذا الأمر يتم عن طريق حساب نصف قطر محيط تأثير الانفجار، والذي اخترناه هنا ليكون طول متجه الحد الأقصى مضروبا في 2. نقوم بعدها باستخدام الدّالة ()FindObjectsOfType من أجل البحث عن جميع الكائنات التي تحتوي على البريمج Breakable (أي الوحدات البنائية)، وأي واحد من هذه الوحدات يقع في محيط التأثير يجب أن تضاف له قوة الانفجار الدافعة متناسبة عكسيا مع المسافة بين مركز الانفجار في الجسم المدمر وبين الوحدة المستهدفة. قوة الانفجار الدافعة ستقوم فقط بتحريك الوحدة البنائية، لكننا نريد أيضا إنقاص صحتها بسبب الانفجار ولذلك نستدعي ()TakeDamage. بعد إضافة البريمجات أصبح الكائن جاهزا لنصنع منه قالب المثلث المعدني. لكون عدد قوالب الأشكال كبيرا نسبيا، من الأفضل وضع كل مجموعة في مجلد خاص بها. مثلا مجلد Metal للأشكال المعدنية و Wood للخشبية وهكذا. بعد صناعة القالب يمكنك أن تغير الكائن نفسه وتغير اسمه ومن ثم تسحبه مرة أخرى إلى مستعرض المشروع لصناعة قالب جديد. مثلا يمكنك استبدال صورة المثلث بالدائرة ومن ثم تغيير الاسم إلى Circle. أيضا سيتوجب عليك تغيير شكل مكوّن التصادم عن طريق إزالة المكوّن Polygon Collider 2D وإضافة المكوّن Circle Collider 2D لأنه الأنسب للدائرة. وبعد صناعة قالب الدّائرة نضيف واحدا آخر للمستطيل مع تغيير مكوّن التصادم مرة أخرى ليصبح Box Collider 2D وهكذا حتى ننجز الأشكال التي نرغب بها. أثناء عملك راعي الأمور التالية: لحذف مكوّن موجود على كائن، قم بالضغط على رمز الترس في أعلى يمين المكوّن في نافذة الخصائص ومن ثم اختر Remove Component.هناك بعض الأشكال مثل المثلث القائم تحتوي على صور لمرحلتي تشقق فقط بينما الأشكال الباقية تحتوي على 3 مراحل. هذا يؤثر فقط على عدد عناصر المصفوفة destructionStages ولا يلزمنا تغيير أي شيء في الكود.مقدار الكتلة للمواد هي 6 للمعدن والمواد المتفجرة و2.5 للزجاج و5 للصخور و1 للخشب.مقدار الصحة الافتراضية هي 100 للمعدن و 45 للمواد المتفجرة و20 للزجاج و 80 للصخور و 35 للخشب.بعد الانتهاء من الوحدات البنائية علينا أن نحل مشكلة صغيرة ستطرأ الآن. تذكر أن الكائنات الموجودة في المشهد حاليا هي الخلفية والأرضية والوحدات البنائية، وجميع هذه الكائنات تحتوي على مكوّنات تصادم بغض النظر عن أشكالها. معنى هذا أن Unity سيقوم باكتشاف التصادمات بين هذه الكائنات مع بعضها، وهذا ما نريده بالنسبة للأرضية والوحدات البنائية التي ستوضع فوقها، لكننا لا نريده للخلفية. فإذا تصادمت الوحدات البنائية مع الخلفية سيختل منطق المشهد تماما. لحل هذه المشكلة يوفر لنا Unity نظام الطبقات Layers والذي يمكننا من خلاله فصل الكائنات إلى طبقات بحيث يمكننا لاحقا التحكم بعلاقات هذه الطبقات مع بعضها. فمثلا يمكن أن نحدد للكاميرا ما هي الطبقات التي يمكنها أن تراها، كما يمكننا أن نحدد أي الطبقات تتصادم مع بعضها البعض. إذن لحل هذا الإشكال علينا أن نضيف طبقة جديدة مخصصة للخلفية، ونمنعها من التصادم مع الطبقة الافتراضية التي تنتمي إليها باقي الكائنات. لإضافة طبقة جديدة قم باختيار قالب الخلفية BGElement ومن ثم افتح القائمة Layer الموجودة في أعلى يمين نافذة الخصائص واختر Add Layer كما ترى في الصورة: ستظهر لك نافذة فيها مصفوفة بجميع الطبقات الافتراضية حيث يمكنك من خلالها إضافة طبقات جديدة خاصة بك. لنقم بإضافة طبقة جديدة اسمها Background، ومن ثم نقوم بتغيير القالب BGElement ليصبح ضمن هذه الطبقة كما ترى في الصورتين التاليتين: بعدها علينا أن نطلب من محاكي الفيزياء الخاص بالمحرك أن يهمل التصادمات الحاصلة بين الطبقة الافتراضية Default وطبقة الخلفية Background بحيث لا تتصادم عناصر الأرضية أو الوحدات البنائية مع الخلفية. لتنفيذ هذا الأمر اذهب إلى القائمة: Edit > Project Settings > Physics 2D حيث ستجد في أسفل نافذة الخصائص مصفوفة تصادم الطبقات Layer Collision Matrix والتي تحتوي على جميع الطبقات الموجودة في المشروع منتظمة في صفوف وأعمدة. في كل نقطة تقاطع بين صف وعمود ستجد مربعا يمكنك تفعيله أو تعطيله. إذا قمت بتعطيل أحد هذه المربعات فإن التصادم الحاصل بين الطبقتين المتقاطعتين فيه سيتم إهماله. بالتالي ما نريده هو تعطيل المربع الذي تتقاطع فيه الطبقة Default مع الطبقة Background كما ترى في الصورة أدناه: بعد تجهيز قوالب الوحدات البنائية يتبقى علينا إضافة قالب قطع الحطام للبريمج BreakablePieces، كما أن علينا تحديد صور القطع الخاصة بكل وحدة بنائية وتحديد عددها وأحجامها. لاختصار الوقت سأعتمد قيما ثابتة لجميع الوحدات البنائية، وهو 5 قطع حطام لكل وحدة وتحجيم بمقدار 0.5 (صور قطع الحطام موجودة في مجلد يسمى Debris). للوقوف على التقدم في بناء قالب الوحدة البنائية، يفترض أن يكون ما أنشأناه حتى الآن شبيها بما تراه في الصورة أدناه: السؤال التالي الذي نريد الإجابة عليه هو: كيف تتناقص قيمة صحة هذه الوحدات البنائية عند سقوطها أو اصطدامها ببعضها البعض أو بمقذوفات اللاعب؟ يساعدنا Unity بالإجابة على هذا السؤال حيث أنه يوفر لنا رسائل إعلام بحدوث أي عمليات تصادم بين الكائنات المختلفة. سنقوم الآن بكتابة بريمج يستقبل رسائل التصادم هذه ويقوم بتحليل محتوياتها وحساب مقدار التدمير الذي يجب أن يتم على كل وحدة بنائية. هذا البريمج هو CollisionDamageTaker، وهو موضح في السرد التالي: using UnityEngine; using System.Collections; public class CollisionDamageTaker : MonoBehaviour { //متغير لتخزين آخر عملية تصادم //يساعد هذا على منع تكرار التصادمات private float lastCollisionTime = 0.0f; //يتم استدعاؤها مرة واحدة عند بداية التشغيل void Start () { } //يتم استدعاؤها مرة عند تصيير كل إطار void Update () { } //يتم استدعاؤها عند حدوث تصادم بين هذا الكائن وكائن آخر void OnCollisionEnter2D(Collision2D col) { if (Time.time - lastCollisionTime < 0.25f) { //لم يمر وقت كاف منذ آخر تصادم، قم بإهمال هذا التصادم ولا تفعل شيئا return; } //قم بتسجيل وقت حدوث التصادم lastCollisionTime = Time.time; //قم باستخراج مكوّن الموقع من الكائن الآخر الذي حدث معه التصادم Transform otherObject = col.transform; //احسب ارتفاع الكائنين المتصادمين float myHeight = transform.position.y; float otherHeight = otherObject.position.y; //ما هو مقدار قوة الإصابة التي تم تلقيها؟ float damageAmount = 1.0f; //قم بحساب قوة الإصابة بناء على موقع //الكائن الآخر if (myHeight < otherHeight) { //سقط كائن آخر فوق هذا الكائن //كم كانت سرعة الجسم الساقط حين اصطدامه؟ float fallVelocity = col.relativeVelocity.magnitude; //اضرب مقدار الإصابة بسرعة الاصطدام damageAmount *= fallVelocity; //ما هي كتلة الكائن الذي سقط فوق هذا الكائن؟ Rigidbody2D otherRB = otherObject.GetComponent<Rigidbody2D>(); float otherMass = otherRB.mass; //اضرب مقدار الإصابة مرة أخرى بالكتلة damageAmount *= otherMass; //قم أخيرا بمضاعفة مقدار الإصابة لأن الكائن الآخر جاء من مكان أعلى damageAmount *= 2.0f; } else { //الكائن الحالي هو الذي سقط //الكائنات الساقطة بسرعة أكبر ستحصل على إصابة أكبر float myFallVelocity = col.relativeVelocity.magnitude; //اضرب مقدار الإصابة بسرعة الاصطدام damageAmount *= myFallVelocity; } //قم بإرسال رسالة لإنقاص الصحة بمقدار الإصابة المحسوبة SendMessage("TakeDamage", damageAmount); } } كما ذكرت فإنّ هذا البريمج يعمل على تقليل صحة كائن الوحدة البنائية بناء على اصطدامها بالكائنات الأخرى في المشهد. المتغير الوحيد الذي عرفناه هنا هو lastCollisionTime والذي يخزن الوقت الذي تم فيه إنقاص الصحة آخر مرة. الهدف من استخدام هذا المتغير هو منع الاصطدامات المتتالية والتي قد تحدث في إطار واحد أو إطارين متتابعين من إنقاص الصحة بسرعة كبيرة، حيث أننا هنا نسمح بإنقاص الصحة مرة كل ربع ثانية فقط. عند تصادم كائنين مع بعضهما يقوم محرك Unity بإرسال الرسالة OnCollisionEnter إلى هذين الكائنين في نفس الوقت، ويرفق مع هذه الرسالة متغيرا من نوع Collision2D والذي يحتوي على معلومات حول التصادم، إضافة لأنه يحتوي على مرجع للكائن الآخر بالتالي يمكن لكل منهما التعرف على الكائن الآخر الذي اصطدم به وخصائصه. الخطوة الأولى للدّالة ()OnCollisionEnter هي أن تتأكد من مرور ربع ثانية على الأقل منذ آخر مرة تم فيها إنقاص صحة الكائن، فإن لم يتحقق هذا الشرط سيتوقف تنفيذ الدّالة على الفور. طريقة حساب مقدار الإصابة (المقدار الذي سيتم إنقاصه من الصحة) يعتمد على عدة عوامل ويبدأ من قيمة افتراضية هي 1. العامل الأول هو ارتفاع كل من الكائنين المتصادمين. لنفترض أن الدّالة اكتشفت أثناء التنفيذ أن الكائن الآخر otherObject ذو ارتفاع أعلى من الكائن الحالي. هذا الأمر يفسر على أن الكائن الآخر قد سقط فوق الكائن الحالي واصطدم به، بالتالي يجب أن يكون مقدار الإصابة أعلى. من أهم المعلومات التي تحملها الرسالة هي col.relativeVelocity، والتي تحسب السرعة النسبية بين الكائنين لحظة الاصطدام. منطقيا يجب أن يتناسب مقدار هذه السرعة طرديا مع قوة الإصابة، لذا فإننا نقوم باستخراج هذا المقدار relativeVelocity.magnitude (السرعة هنا هي متجه لذلك نستخرج مقدارها) وضربه بقيمة قوة الإصابة. بعد ذلك نقوم باستخراج مكوّن الجسم الصلب RigidBody2D من الكائن الآخر وضرب كتلته بمقدار الإصابة، حيث يجب أن تزيد الإصابة كلما كان الكائن الذي سقط ذا كتلة أكبر. أخيرا نقوم بمضاعفة القيمة النهائية التي حسبناها؛ وذلك لجعل سقوط كائن فوق آخر ذا تأثير أكبر من سقوط الكائن نفسه على الأرض أو على جسم آخر. الاحتمال الآخر هو أن يكون الكائن الحالي ذا ارتفاع أعلى من الكائن الآخر الذي سقط فوقه، بالتالي لا حاجة لنا بأخذ أية معلومات من هذا الجسم الآخر. كل ما سنفعله في هذه الحالة هو ضرب قيمة الإصابة بسرعة الاصطدام، مما يجعل سقوط الجسم نفسه أقل أذى من سقوط جسم آخر فوقه. بعد المرور على أحد هذين الاحتمالين نحصل على قيمة الإصابة النهائية في المتغير damageAmount وبالتالي نرسل الرسالة TakeDamage ونرفق معها القيمة المحسوبة. لاحظ أن البريمج المرسل CollisionDamageTaker والمستقبل Breakable موجودان على نفس الكائن، ولا يوجد أي كائن آخر مهتم باستقبال هذه الرسالة. لهذا السبب استخدمنا الدّالة()SendMessage في عملية الإرسال. بقي علينا خطوة واحدة لإكمال قالب الوحدة البنائية، وهي الصوت الذي سيصدر من الوحدة البنائية حين تحطمها. هذا الصوت سيختلف باختلاف مادة الوحدة البنائية؛ فصوت تحطم الزجاج ليس كالحجر وليس كالمعدن أو الخشب. هذا الاختلاف سينعكس على ملف الصوت المستخدم، إلا أن التقنية ستكون نفسها وستعمل كلها باستخدام نفس البريمج وهو BreakableSounds. سيعمل هذا البريمج على إصدار صوت في حالتين: عند تلقي إصابة بمقدار قوي، وعند تحطم الوحدة البنائية نهائيا. الهدف من حصر الصوت الأول بالإصابات ذات المقدار القوية هو أن عددا كبيرا من التصادمات يمكن أن يحدث في نفس الإطار مما سينتج عنه أصوات كثيرة مزعجة وهي نتيجة ليست مرضية على الإطلاق. هذا البريمج موضح في السرد التالي: using UnityEngine; using System.Collections; public class BreakableSounds : MonoBehaviour { //يتم تشغيل هذا الصوت عند تلقي إصابة public AudioClip damageSound; //يتم تشغيل هذا الصوت عند تحطم الوحدة البنائية public AudioClip destructionSound; //الحد الأدنى لمقدار الإصابة التي تؤدي لتشغيل الصوت public float minDamage = 25.0f; //المدة الزمنية التي يمنع خلالها تشغيل أي صوت من قبل هذا الكائن private float suspensionTime = 0.0f; //الوقت الذي بدأ فيه تشغيل آخر صوت من قبل هذا الكائن private float playTime = 0.0f; //تستدعى مرة واحدة عند بداية التشغيل void Start () { } //تستدعى مرة عند تصيير كل إطار void Update () { } //DamageTaken تقوم باستقبال رسالة الإصابة void DamageTaken(float amount) { //سيتم تشغيل الصوت فقط للإصابات التي //يزيد مقدارها عن الحد الأدنى if (amount >= minDamage) { CheckAndPlay(damageSound); } } //BreakableDestroyed تقوم باستقبال رسالة تدمير الوحدة البنائية void BreakableDestroyed() { AudioSource.PlayClipAtPoint(destructionSound, transform.position); } //تقوم بحساب الوقت الذي يمنع فيه تشغيل ملف الصوت وتقرر بناء عليه تشغيل الملف مجددا من عدمه void CheckAndPlay(AudioClip clip) { if (Time.time - playTime > suspensionTime) { AudioSource.PlayClipAtPoint(clip, transform.position, 0.25f); playTime = Time.time; suspensionTime = clip.length + 0.5f; } } } لعل المهام الرئيسية لهذا البريمج واضحة: فهو يستقبل الرسالتين DamageTake و BreakableDestroyed من البريمج Breakable ويقوم بناء عليها بتشغيل واحد من ملفي الأصوات المحددين وهما من نوع AudioClip، واللذين يمكننا اختيارهما من نافذة الخصائص كما سنرى بعد قليل. المتغير minDamage يمكننا من اختيار الحد الأدنى للإصابة التي ستؤدي إلى تشغيل ملف الصوت، وهو افتراضيا 25. لاحظ أن تشغيل صوت الإصابة يتم بطريقة تختلف عن تشغيل صوت التدمير؛ ذلك أن الأخير لا يمكن أن يتكرر كما هو الحال في الأول. بطبيعة الحال فإن تلقي عدة إصابات في فترة قصيرة أمر ممكن، لذلك علينا أن نمنع تكرار تشغيل ملف الصوت مرات متتابعة بشكل مزعج. لأجل ذلك نقوم عند تشغيل ملف صوت الإصابة بحساب طول الملف وإضافة نصف ثانية إليه، ومن ثم تخزين الناتج في المتغير suspentionTime. عند محاولة تشغيل ملف صوت الإصابة مرة أخرى نقوم بمقارنة الوقت المنقضي منذ آخر تشغيل بوقت منع التشغيل suspentionTime. إذا كانت هذه المدة أقل من وقت المنع فلن يتم تشغيل الملف. هذه الخطوات تقوم بها الدّالة ()CheckAndPlay. بعد إضافة هذا البريمج لقالب الوحدة البنائية، سيظهر المتغيران damageSound و destructionSound في نافذة الخصائص على شكل خانات يمكن أن تضيف إليها ملفات صوتية. لكل نوع من أنواع الوحدات البنائية الخمسة (المعدن والمتفجرات والحجر والخشب والزجاج) سيكون هناك ملفات مختلفة. هذه الملفات تم جلبها من مواقع مختلفة حيث لا يتوفر دائما مكتبة صوتية شاملة يمكن الاعتماد عليها كمصدر لكل المؤثرات الصوتية، أو على الأقل لا تتوفر مجانا. كما ذكرت سابقا تم وضع الملفات في مجلدات تحمل أسماء المواقع التي تم إحضارها منها، مع الإبقاء على اسم الملف الأصلي كما هو في الموقع دون تغيير. الصورة التالية توضح شكل البريمج BreakableSounds لكائنين أحدهما وحدة بنائية خشبية والأخرى حجرية. وبهذا نكون قد انتهينا من قوالب الوحدات البنائية بشكل عام. الصورة الأخيرة في الأسفل توضح الشكل العام لقوالب الوحدات البنائية مع كافّة المكوّنات اللازمة، والتي تم إغلاقها اختصارا للمساحة. بقي أمر واحد سنضيفه كلمسة جمالية على لعبتنا، وهي حدوث انفجار مرئي عند تحطم الوحدات البنائية المتفجرة. هذا الانفجار موجود في الحزمة Explosion Pack في موقع kenney.nl والذي سبق وضع روابطه، وهو يحمل الاسم Regular Explosion. الانفجار عبارة عن 9 صور متتابعة كما ترى في الصورة في الأسفل: ما يتوجب فعله الآن هو أن نصنع قالبا خاصا بالانفجار بحيث يعرض هذه الصور التسعة بشكل متتابع، ومن ثم نقوم بكتابة بريمج يعمل على إنشاء كائن من هذا القالب في حال تحطم أحدى الوحدات البنائية المتفجرة. لنبدأ مع قالب الانفجار والذي سيحتوي على مكوّن واحد هو SpriteRenderer إضافة للبريمج SpriteAnimator الموضح في السرد التالي، والذي سيعمل على عرض الصور بشكل متتابع. using UnityEngine; using System.Collections; public class SpriteAnimator : MonoBehaviour { //مصفوفة تحتوي على اللقطات التي تشكل الحركة public Sprite[] frames; //سرعة التحريك مقدرة بعدد اللقطات في الثانية public float framesPerSecond = 16.0f; //هل يجب أن يتم تدمير الكائن وحذفه بعد انتهاء التحريك؟ public bool destroyOnCompletion = true; //متغير لتسجيل الوقت الذي تم فيه تغيير اللقطة آخر مرة private float lastChange = 0.0f; //متغير مرجعي لمكوّن تصيير الصور ثنائية الأبعاد private SpriteRenderer renderer; //موقع اللقطة المعروضة حاليا في مصفوفة اللقطات private int currentFrame = 0; //يتم استدعاؤها مرة واحدة عند بداية التشغيل void Start () { renderer = GetComponent<SpriteRenderer>(); } //يتم استدعاؤها مرة عند تصيير كل إطار void Update () { Animate(); } //تقوم بعملية التحريك عن طريق عرض اللقطات بشكل متتابع void Animate() { //احسب المدة التي يجب أن تقضيها اللقطة الواحدة float frameTime = 1.0f / framesPerSecond; //قم بالتبديل للإطار التالي في حال انقضى الوقت اللازم للقطة السابقة if (Time.time - lastChange > frameTime) { lastChange = Time.time; currentFrame = (currentFrame + 1) % frames.Length; renderer.sprite = frames[currentFrame]; if (currentFrame == 0 && destroyOnCompletion) { Destroy(gameObject); } } } } ببساطة شديدة يقوم هذا البريمج عند تصيير كل إطار باستدعاء الدّالة Animate، والتي تقوم بحساب الوقت اللازم لعرض كل لقطة عن طريق قسمة الرقم 1 على السرعة. بعد ذلك تقارن الوقت المنقضي منذ تم التبديل للقطة الحالية مع الوقت اللازم للقطة الواحدة، فإذا انقضى هذا الوقت تقوم بالتبديل إلى اللقطة التالية في المصفوفة frames. من البديهي هنا أن تتم إضافة لقطات الانفجار بشكل مرتب إلى المصفوفة frames وذلك حتى تظهر بالتتابع الصحيح كما هو موضح في الصورة التالية. لاحظ أخيرا أننا نقوم بتدمير الكائن بعد انتهاء عرض اللقطات لأننا لا نريد تكرار مشهد الانفجار سوى مرة واحدة. بعد إنشاء قالب الانفجار علينا أن نربط إنشاء الانفجار بتدمير الوحدة البنائية المتفجرة. هذه عملية بسيطة لا تتطلب أكثر من استقبال الرسالة BreakableDestroyed ومن ثم إنشاء كائن جديد من قالب الانفجار في نفس موقع الوحدة البنائية التي تم تدميرها. هذه المهمة يقوم بها البريمج BreakableSpawn والموضح في السرد التالي: using UnityEngine; using System.Collections; public class BreakableSpawn : MonoBehaviour { //قالب الكائن الذي سيتم إنشاؤه بعد التدمير public GameObject prefab; //يتم استدعاؤها مرة عند بداية التشغيل void Start () { } //يتم استدعاؤها مرة عند تصيير كل إطار void Update () { } //BreakableDestroyed تقوم باستقبال رسالة تدمير الوحدة البنائية void BreakableDestroyed() { //قم بإنشاء كائن جديد كم القالب وضعه في نفس موقع الكائن الحالي الذي تم تدميره GameObject newObject = (GameObject)Instantiate(prefab); newObject.transform.position = transform.position; newObject.transform.parent = transform.parent; } } إنشاء كائنات الخصومكما تعلم تتكون المرحلة الواحدة في هذه الألعاب من العناصر البنائية والخصوم ومقذوفات اللاعب التي يطلقها على هذه الوحدات والخصوم التي تحتمي بها. بالتالي فالعنصر الثاني الذي يلزمنا بعد تجهيز الوحدات البنائية هو شخصيات الخصوم. بالاطلاع على المجلد Aliens في مجموعة الصور، تلاحظ وجود خمسة أنواع من الكائنات بأشكال غريبة، بعضها مربع الشكل والآخر دائري، وبعضها يرتدي ما يشبه الخوذة. من ناحية السلوك الفيزيائي لا تختلف كائنات الخصوم عن الوحدات البنائية؛ حيث أنها تمتلك صحة محددة تنقص بتلقي الضربات، كما أنها تتأثر بالانفجارات والتصادمات وما شابه. هذا يعني – لحسن الحظ – أن المجهود الذي بذلناه في كتابة البريمجات السابقة الخاصة بقوالب الوحدات البنائية سيعطي ثماره في هذه المرحلة، حيث سنعيد استخدام تلك البريمجات من أجل صنع قوالب الخصوم، ولن نحتاج لكتابة أي بريمج جديد باستثناء واحد سنتحدث عنه بعد قليل. كل ما علينا هو إضافة البريمجات Breakable و BreakablePieces و CollisionDamageTaker و BreakableSounds، ومن ثم تحديد القيم المناسبة لكل نوع من أنواع الوحوش. اختصارا للوقت سأعتبر الأشكال الأربعة ذات نفس الخصائص، إضافة لمنح الوحوش التي تحتوي على خوذة مقدارا أعلى من الصحة. ستكون صحة الوحشين المربع والدائري هي 50، بينما الوحش ذو الخوذة ستكون صحته 100. وهذا سينطبق على الألوان الخمسة للوحوش. بطبيعة الحال علينا أيضا إضافة مكوّن الجسم الصلب RigidBody2D و أحد مكوّني التصادم BoxCollider2D أو CircleCollider2D وذلك حسب شكل الوحش. الشكل التالي يوضح القالب الخاص بأحد الوحوش التي ترتدي خوذة. يمكنك الاطلاع على قوالب الوحوش في المجلد Prefabs\Enemies: هناك اختلافات أخرى يجب مراعاتها بين الوحدات البنائية والوحوش، فهناك ملفات صوتية مختلفة للإصابة والتدمير، إضافة لأن تدمير الوحوش ينتج عنه نجوم بدلا من قطع الحطام. صور النجوم هذه موجودة في المجلد PhysicsPack\Others وهي موضحة في الشكل التالي. لاحظ أيضا أن عدد النجوم الناتجة كبير نسبيا وهو 10: كل الفروق السابقة بين الوحدات البنائية والوحوش هي فروق شكلية، لكن الفرق الأهم هو أن نكون قادرين على التمييز بينهما برمجيا؛ وذلك لأن تدمير الوحوش يختلف عن تدمير الوحدات البنائية وهو ذو علاقة مباشرة بحالة اللعبة. فتدمير جميع الوحوش في المشهد سيؤدي لفوز اللاعب بالمرحلة وانتقاله للمرحلة التالية، إلا أن هذا ليس هو الحال بالنسبة للوحدات البنائية. ولأن تدمير الوحوش أمر مهم، علينا أن نضيف لقالب كل وحش منها بريمجا يقوم بإرسال رسالة للكائن الجذري حين تدمير كائن الوحش، وسيتكفل بريمج آخر سنأتي عليه بعد حين بمهمة استقبال الرسالة وعمل ما يلزم حيالها. ما يهم الآن هو أن نضيف البريمج Enemy والموضح في السرد التالي لكل قوالب الوحوش التي أنشأناها. using UnityEngine; using System.Collections; public class Enemy : MonoBehaviour { //يتم استدعاؤها مرة عند بداية التشغيل void Start () { } //يتم استدعاؤها مرة عند تصيير كل إطار void Update () { } //ومن ثم BreakableDestroyed تقوم باستقبال الرسالة //ترسل رسالة للأعلى أي للكائن الجذري بأن الوحش تم تدميره void BreakableDestroyed() { SendMessageUpwards("EnemyDestroyed"); } }:بهذا أصبح لدينا كل ما يلزم لبناء مشهد، وما عليك سوى رص الوحدات البنائية والوحوش بأي ترتيب ترغب به من أجل صنع مرحلة كما في الصورة التالية .وبهذا نكون انتهينا من تجهيز مشهد اللعبة. سنقوم في الدرس القادم ببناء واجهة المستخدم والشاشة الرئيسية للعبة
-
- unity3d
- وحدات بنائية
-
(و 3 أكثر)
موسوم في:
-
قمنا في الدرس السابق بالتعرف على محرك Unity وكيفية تحميله وإنشاء مشروع جديد عليه، سنبدأ في هذا الدرس في مشروع عملي لتعلم كيفية صناعة لعبة حقيقية كاملة. سنتكلم في الدرس الأول عن إعداد مشهد اللعبة والتحكم بالكاميرا. ملاحظة: يمكن تحميل الملفات المصدرية لكامل هذه السلسلة عبر حساب أكاديمية حسوب على Github، يمكن أيضا تحميل ملف APK لتجريب اللعبة على أجهزة Android. استيراد الصور وتجهيزها سأستخدم في هذا المثال مجموعة من الرسوم المطروحة مجانا تحت رخصة المشاع الإبداعي CC0 وهي متوفرة على الموقع http://kenney.nl. المجموعة الأولى عبارة عن رسومات خاصة بالألعاب الفيزيائية مثل Angry Birds، وهي اللعبة التي نحاول أن نحاكي ميكانيكياتها عبر هذه السلسلة. أما المجموعة الثانية فهي عبارة عن رسوم حيوانات بسيطة سنستخدمها بدلا عن الطيور الموجودة في اللعبة الأصلية بحيث تمثل المقذوفات. إلا أننا سنضيف بعض الرسومات الأخرى وملفات الأصوات حسب الحاجة. سأعتمد في هذه السلسلة وضع كل مصدر (ملف صورة أو ملف صوت) داخل مجلد يحمل اسم الموقع الذي تم جلب المصدر منه، بحيث يمكنك الرجوع لاحقا لهذه المواقع وتحميل الملفات. بعد تحميل هذه الرسومات وفك ضغطها قم بنسخها إلى داخل المجلد Assets الخاص بمشروعك، يمكنك نسخها عن طريق نظام التشغيل أو بسحبها مباشرة إلى داخل المجلد في مستعرض Unity. بمجرد أن تضيف هذه الملفات للمشروع سيتعرف عليها Unity على أنها رسومات شبحية sprites، وهي أنواع الرسوم الأكثر شيوعا في الألعاب ثنائية الأبعاد. من أجل الحصول على أعلى جودة ممكنة سنقوم بتعديل بعض خصائص الرسومات، سأضرب مثالا هنا على مجلد واحد ومن ثم علينا تكرار العملية لجميع مجلدات الرسومات. ملاحظة: تحتوي مجموعات الرسومات على طريقتين مختلفتين للتخزين: الأولى تخزين كل صورة على حدى في ملف مستقل، والثانية جمع الصور في ملف واحد يسمى ورقة الرسومات sprite sheet. سنتناول في هذه الدروس الطريقة الأولى وهي الأشكال المنفصلة؛ وذلك لسهولة العمل عليها. علما بأن الطريقة الثانية أكثر كفاءة من ناحية الأداء وأسهل في حالة الرسومات المتحركة. كما ترى في الشكل التالي فإنني قمت باختيار جميع عناصر مجلد رسومات الوحوش والتي ستمثل الخصوم في هذه اللعبة. بعد ذلك قمت بتعديل خصائص استيراد هذه الرسومات لتصبح كالتالي: بتغيير نوع المرشِّح إلى Trilinear وتنسيق الألوان إلى Truecolor فإننا نطلب من Unity ألا يقوم بضغط هذه الصور بحيث تبقى بحجمها الأصلي وتحافظ على جودتها. على الرغم من أن الضغط أفضل للأداء، إلا أنك ستلاحظ أن Unity يخبرك أن معظم هذه الرسومات غير قابلة للضغط كونها لا تحقق قاعدة أن يكون الطول والعرض مساويين لأرقام لوغاريتمية للأساس 2 (مثلا 64، 128، 512، إلخ). لا تنس أن تضغط على Apply لحفظ التغييرات بعد الانتهاء منها. بناء المشهد وتجهيز الخلفية بعد تغيير الخصائص لجميع الرسومات التي استوردناها يمكننا أن نبدأ بإعداد المشهد. الخطوة الأولى ستكون بإضافة الخلفية والأرضية التي ستدور أحداث اللعبة عليها. إحدى المواصفات المهمة في عملية الاستيراد هي Pixels Per Unit. الرقم 100 هنا يعني أن خطا بطول 100 بكسل في الصورة الأصلية يغطي مسافة قدرها وحدة واحدة في فضاء Unity. فلو افترضت أن الوحدة هي متر واحد، فإن صورة صندوق بحجم 10×10 بكسل تساوي صندوقا بحجم 10×10 سم في فضاء اللعبة. لحسن حظنا فإن مجموعة الصور التي استوردناها ذات أحجام مناسبة لبعضها البعض، فمثلا صور الوحوش هي ذات مقاس 70×70 بكسل بينما الخلفيات أكبر من ذلك بكثير وتصل إلى 1024×1024 بكسل. لنأخذ بعين الاعتبار منذ البداية أن هذه السلسلة ليست لتعلم برمجة الألعاب فقط، كما أنها ليست لتعلم محرك Unity فقط، بل يمكنك القول أنها ستعلمك أيضا أفضل الممارسات البرمجية والتنظيمية للمشهد لتحصل على أفضل نتيجة باستخدام هذا المحرك. من أجل ذلك سنقوم بإعداد مشهد اللعبة بطريقة خاصة بحيث يحتوي المشهد على كائن واحد نسميه الكائن الجذري للمشهد SceneRoot ومن ثم سنضيف كل عناصر المشهد على شكل أبناء وأحفاد لهذا الكائن. لإضافة كائن جديد كل ما عليك هو اختيار القائمة: Game Object > Create Empty وسيظهر بعدها الكائن الجديد في هرمية المشهد باسم GameObject، قم باختياره وتغيير اسمه إلى SceneRoot وموقعه إلى نقطة الأصل (0 ,0). كما ذكرت في مقدمة هذا الفصل يمكنك تغيير هذه القيم من نافذة الخصائص Inspector كما هو موضح في الصورة أدناه. لاحظ أن هذا الكائن "فارغ" كما يدل اسمه، أي أنه موجود في الذاكرة فقط ولا يمكن رؤيته في المشهد رغم أنه موجود في وسطه، سنأتي إلى هذه التفاصيل حسب حاجتنا لها. بعد ذلك سنضيف كائنا فارغا آخر ليمثل خلفية المشهد، كل ما عليك هو أن تضغط بزر الفأرة الأيمن على الكائن SceneRoot في الهرمية وتختار Create Empty من أجل أن تضيف كائنا فرعيا (ابنا) للكائن الجذري ولنقم بتسميته Background. أخيرا عليك أن تقوم بسحب كائن الكاميرا Main Camera إلى داخل الكائن الجذري بحيث تصبح هرمية المشهد بهذا الشكل. حان الوقت الآن لحفظ المشهد الذي نعمل عليه، كما هو الحال في 99% من البرامج يمكنك الحفظ عن طريق Control+S وهو أمر خاص بحفظ المشهد وليس حفظ المشروع كاملا، حيث أن المشروع ما هو إلا مجموعة ملفات يتم تعديلها وحفظها على حدى. قم بإنشاء مجلد جديد داخل Assets وقم بتسميته Scenes ومن ثم احفظ المشهد داخله باسم GameScene. لنتفق من الآن على أننا سنقوم ببناء مشهد واحد لكل اللعبة، وسنعمل على تغيير كل من الخلفية والأرضية ومواقع الكائنات لصنع مراحل مختلفة. الكائن الأول سيكون خلفية المشهد، والذي توجد عدة صور يمكن استخدامها له. هذه الصور عددها 8 وهي موجودة في المجلد Backgrounds في مجموعة الصور الأولى كما ترى في الصورة أدناه. ما يميز صور الخلفيات هذه هو أن كلا منها ينطبق طرفها الأيمن على طرفها الأيسر تماما مما يجعلنا قادرين على تكرارها أفقيا بشكل متتابع دون أن يلاحظ اللاعب وجود حدود بينها. بما أننا نتعامل مع لعبة ذات مشهد عرضي فإن علينا أن نكرر الخلفية أكثر من مرة بشكل أفقي، ولتكن 4 مرات. قبل أن نبدأ بإضافة الخلفيات للمشهد، لنسهل الأمر على أنفسنا بجعل نقطة الارتكاز Pivot لجميع صور الخلفيات هي الطرف الأيسر من الصورة بدل الأوسط، وذلك كما ترى في الصورة التالية: ولكن ماذا يعني أن تكون نقطة ارتكاز الصورة هي طرفها الأيسر وليس وسطها؟ الصورتان في الأسفل تجيبان على هذا السؤال. في كلتا الحالتين فإن موقع الصورة لم يتغير وهو (0 ,0) أي في منتصف المشهد. في الوضع الافتراضي (الصورة اليسرى) فإن ارتكاز الصورة يكون في منتصفها، أي أن موقع الصورة في الفضاء هو موقع منتصفها، ويمتد طرفا الصورة الأيسر والأيمن بين النقطتين x=-5.12 و x=+5.12. أما في الحالة الثانية قمنا بتغيير الارتكاز لأقصى يسار الصورة أفقيا مع بقائه في منتصفها عموديا. بهذا الشكل أصبحت الصورة تمتد من النقطة x=0 يسارا إلى النقطة x=+10.24 يمينا. لاحظ أن قيم هذه النقاط متوافقة مع حقيقة أن الوحدة الواحدة =100 بكسل حيث يكون عرض الصورة بالوحدات هو 10.2=1024/100 بهذا أصبح بإمكاننا أن نحسب مواقع الصور الأربعة التي سنقوم برصّها بجانب بعضها البعض من اليسار لليمين حول نقطة الأصل، وهي كالتالي: الصورة في أقصى اليسار ستكون في الموقع 20.48- وتمتد إلى 10.24-. الصورة على يسار نقطة الأصل ستكون في الموقع 10.24- وتمتد إلى 0.0. الصورة على يمين نقطة الأصل ستكون في الموقع 0.0 وتمتد إلى 10.24+. الصورة في أقصى اليمين ستكون في الموقع 10.24+ وتمتد إلى 20.48+. بدلا من أن نقوم بوضع هذه الصورة يدويا، سنقوم بكتابة بُريمج صغير script بحيث نعطيه صورة ونطلب منه أن يكررها عددا من المرات ليصنع لنا الخلفية. هذا البريمج سيعمل بالشكل التالي: سنقوم بإعطائه جميع صور الخلفيات الموجودة لدينا على شكل مصفوفة بحيث تحمل كل صورة رقما خاصا بها، إضافة لذلك سنكون قادرين على تزويده بعدد مرات التكرار المطلوبة للصورة من أجل رسم الخلفية. عند تشغيل اللعبة فإنه يجب علينا أن نقوم باختيار رقم الخلفية التي نرغب بظهورها على الشاشة، وهذا الرقم سيكون من 0 إلى 7. سنعود لموضوع البريمج هذا بعد قليل. قبل الخوض في التفاصيل أود التنويه إلى أن الكائنات في محرك Unity جميعها تتشابه، وهي في وضعها الأصلي فارغة غير مرئية، وما يميز كل كائن عن غيره ويعطيه سماته الخاصة هو مجموعة المكوّنات التي تتم إضافتها إليه. المكوّن الوحيد الموجود في الكائن الفارغ هو Transform، وهو موجود في جميع كائنات المحرك دون استثناء، حيث أنه يعطي الكائن موقعه ودورانه وحجمه في الفضاء ثنائي الأبعاد (أو ثلاثي الأبعاد) إضافة إلى أنه يحدد علاقاته بالكائنات الأخرى كعلاقة الأب والأبناء التي نراها في هرمية المشهد. بناء على هذه الحقيقة، فإن ما تراه في شاشة الخصائص Inspector حين تختار أي كائن هو مجموعة المكوّنات المضافة إليه. ما سنقوم به الآن هو بناء قالب prefab، وهو عبارة عن كائن نضيف إليه بعض المكوّنات، ومن ثم يمكننا إنشاء عدة نسخ منه أثناء تشغيل اللعبة. الأمر الجيد في هذه القوالب هو أنه يمكن تعديلها من مكان واحد مهما بلغ عدد الكائنات التي يتم إنشاؤها منها، حيث ينعكس أي تعديل في القالب الأصلي (كإضافة مكون أو حذفه) على جميع الكائنات التي تتبع هذا القالب. القالب الذي سنقوم به خاص بالكائن الذي سيعرض صورة الخلفية، والذي سيحتاج مبدئيا لمكوّن واحد وهو Sprite Renderer الخاص بتصيير الصور ثنائية الأبعاد على الشاشة. لإنشاء القالب لنقم أولا بإضافة كائن فارغ للمشهد ومن ثم نضيف له المكوّن المذكور وذلك عن طريق الزر Add Component والذي يعطيك قائمة بجميع المكوّنات المتوفرة، ولتسهيل الوصول يمكنك البحث عن طريق كتابة اسم المكوّن Sprite Renderer ومن ثم اختياره كما يلي: بعد إضافة المكوّن أصبحنا جاهزين لتحويل هذا الكائن إلى قالب، وذلك عن طريق سحبه من الهرمية إلى داخل أي مجلد في المشروع، في هذه الحالة سأقوم بإنشاء مجلد جديد داخل Assets وأسميه Prefabs لأننا سنحتاج في هذا العمل إلى عدد كبير من القوالب كما سنرى. بعدها قم بتغيير اسم القالب ليصبح BGElement. بعد إنشاء القالب لم نعد بحاجة للكائن في المشهد بالتالي يمكننا حذفه باستخدام مفتاح Delete. انتبه هنا أنك تحذف الكائن من الهرمية ولا تحذف القالب من المشروع فهما يحملان نفس الاسم لكن الفرق كبير بين الأمرين! لإضافة بريمج جديد للمشروع لنقم أولا بإنشاء مجلد خاص بالبريمجات داخل Assets ولنسمه Scripts. بعدها يمكنك إضافة بريمج جديد بلغة #C وذلك عن طريق الضغط على المجلد بزر الفأرة الأيمن ومن ثم اختيار: Create > C# Script. أدخل الاسم BackgroundManager ومن ثم اضغط Enter وبعدها قم بفتح الملف على محرر MonoDevelop أو Visual Studio وذلك بالنقر المزدوج. لنشاهد البريمج الذي سنكتبه في السرد التالي ثم نناقشه معا بالتفصيل. using UnityEngine; using System.Collections; public class BackgroundManager : MonoBehaviour { //القالب المستخدم لبناء عناصر الخلفية public GameObject bgElementPrefab; //مصفوفة تحتوي على جميع صور الخلفيات المتاحة public Sprite[] selectionList; //عدد مرات تكرار صورة الخلفية public int repeatCount = 4; //صورة الخلفية المعروضة حاليا private int selectedIndex = -1; //"Background" مرجع للكائن الابن المسمى private Transform bgGameObject; //تستدعى مرة واحدة عند بداية التشغيل void Start () { //"Background" ابحث في الأبناء على الكائن الذي يحمل الاسم bgGameObject = transform.FindChild("Background"); //قم مبدئيا باختيار الصورة الأولى كخلفية ChangeBackground(0); } //تستدعى مرة واحدة عند تصيير كل إطار void Update () { } //تقوم بتغيير صورة الخلفية المعروضة حاليا public void ChangeBackground(int newIndex){ if(newIndex == selectedIndex){ //لا حاجة لتغيير الصورة return; } //قم بحذف صور الخلفية الحالية for(int i = 0; i < bgGameObject.childCount; i++){ Transform bgSprite = bgGameObject.GetChild(i); Destroy(bgSprite.gameObject); } //قم بتخزين صورة الخلفية الجديدة من المصفوفة حسب الموقع المعطى Sprite newSprite = selectionList[newIndex]; //ما هو عرض صورة الخلفية بالبكسل؟ float width = newSprite.rect.width; //ما هو ارتفاع صورة الخلفية بالبكسل؟ float height = newSprite.rect.height; //كم عدد البكسلات في الوحدة الواحدة؟ float ppu = newSprite.pixelsPerUnit; //احسب الطول والعرض مستخدما الوحدات width = width / ppu; height = height / ppu; // قم بحساب الموقع الأفقي للصورة الأولى في أقصى اليسار float posX = -width * repeatCount * 0.5f; //قم بحساب حدود المشهد الجديدة Vector2 boundsSize = new Vector2(width * repeatCount, height); Bounds newBounds = new Bounds(Vector2.zero, boundsSize); //قم بإرسال رسالة تخبر بتغير حدود المشهد BroadcastMessage("SceneBoundsChanged", newBounds); //يمكننا الآن البدء ببناء الخلفية الجديدة for(int i = 0; i < repeatCount; i++){ //قم ببناء كائن جديد مستخدما القالب GameObject bg = (GameObject) Instantiate(bgElementPrefab); //قم بتحديد اسم الكائن bg.name = "BG_" + i; //قم بجلب مكوّن تصيير الصور الموجود في الكائن SpriteRenderer sr = bg.GetComponent<SpriteRenderer>(); //قم بتحديد الصورة التي سيتم تصييرها لتكون الصورة المختارة من المصفوفة sr.sprite = newSprite; //قم بضبط الموقع الأفقي مستخدما القيمة التي سبق وحسبناها bg.transform.position = new Vector2(posX, 0); //قم بإرجاع صورة الخلفية خطوة للخلف في ترتيب الرسم sr.sortingOrder = -1; //كأب لعنصر الخلفية الذي تم إنشاؤه "Background" قم بتحديد الكائن الفارغ bg.transform.parent = bgGameObject; //قم بإضافة مكون تصادم على شكل مربع bg.AddComponent<BoxCollider2D>(); //قم بإضافة عرض الصورة إلى قيمة الموقع الأفقي لحساب الموقع الجديد للعنصر التالي posX += width; } //أرسل رسالة تخبر بتغير صورة الخلفية وأرفق معها موقع الصورة الجديدة BroadcastMessage("BackgroundChanged", newIndex); } } يتعامل محرك Unity مع البريمجات على أنها مكوّنات يمكن إضافتها إلى أي كائن في المشهد أو قالب في المشروع (القوالب في النهاية هي كائنات). بالنسبة لنا فهذا بريمج أساسي يتعلق بإدارة المشهد ككل وليس باللاعب أو الخصم، بالتالي سنضيفه إلى الكائن الجذري وذلك ببساطة عن طريق سحب ملف BackgroundManager.cs من مستعرض المشروع إلى الكائن SceneRoot سواء في الهرمية أو في شاشة خصائصه. بعد إضافته ستلاحظ أن البريمج ظهر في شاشة خصائص الكائن الجذري وظهرت المتغيرات العامة bgElementPrefab و selectionList و repeatCount بحيث يظهر الأول على شكل خانة فارغة ويظهر الثاني على شكل قائمة يمكنك تحديد عدد عناصرها من الخانة size، والثالث على شكل مربع نص يقبل قيمة رقمية. لنر الآن كيف يمكننا تحديد قيم هذه المتغيرات وستكون البداية مع bgElementPrefab. كما هو متوقع فهذه الخانة مخصصة لتحديد القالب الذي سيستخدم لاحقا لإنشاء عناصر الخلفية، وهو BGElement الذي سبق وأنشأناه. لربط القالب بهذا المتغير كل ما عليك هو أن تقوم بسحبه من مستعرض المشروع إلى داخل الخانة التي تراها في شاشة الخصائص. بعد ذلك قم بتغيير عدد العناصر في القائمة selectionList إلى 8 ومن ثم اسحب لكل خانة منها واحدة من صور الخلفية الموجودة في المجلد Backgrounds، أما المتغير الثالث repeactCount فقد أخذ قيمة افتراضية هي 4 وهي القيمة التي استخدمناها عند تعريفه. سيصبح شكل الكائن الجذري الآن كالتالي: لو جربت تشغيل اللعبة الآن ستلاحظ ظهور صورة الخلفية الموجودة في العنصر الأول من القائمة، كما ستلاحظ أنها تكررت أفقيا 4 مرات بشكل صحيح كما ترى أدناه، وتلاحظ أيضا الكائنات الأربعة لهذه الصور والتي تمت إضافتها كأبناء للكائن Background في الهرمية. سنناقش الآن بشكل سريع أهم آليات عمل البريمج BackgroundManager، جميع البريمجات في محرك Unity تأخذ تقريبا نفس التنسيق، حيث تبدأ بتعريف المتغيرات اللازمة، ومن ثم الدّالتين الأكثر استخداما وهما ()Start و ()Update. كما هو موضح في التعليقات على الكود فإنّ الدّالة ()Start تستدعى مرة واحدة فقط عند بداية التشغيل إن كان الكائن موجودا في المشهد أصلا، أو بمجرد إضافته للمشهد إن لم يكن موجودا من قبل. بعد ذلك تدخل اللعبة فيما نسميها حلقة التحديث أو حلقة تصيير الإطارات حيث يتم استدعاء الدّالة ()Update عند تصيير كل إطار طالما أن اللعبة تعمل. في حالتنا هذه لا يلزمنا استخدام ()Update. لاحظ أننا بدأنا البريمج بتعريف عدد من المتغيرات بعضها عام public وبعضها الآخر خاص private. بالنسبة لمحرك Unity فإن المتغيرات العامة ذات أهمية خاصة لأنه يعرضها على شكل خانات في شاشة الخصائص مما يسمح لك بتغيير قيمها دون تعديل الكود كما سبق ورأينا. فيما يلي شرح هذه المتغيرات: bgElementPrefab يستخدم كمرجع للوصول للقالب الذي سنستخدمه لإنشاء عناصر الخلفية عند بنائها. selectionList مصفوفة تحتوي على عناصر من نوع Sprite حيث تقوم بتخزين قائمة بجميع الخلفيات التي يمكن استخدامها. repeatCount عدد صحيح يمثل عدد مرات تكرار الخلفية أفقيا، بطبيعة الحال فزيادة هذا العدد سيؤدي لزيادة عرض الخلفية النهائية. selectedIndex يمثل موقع الخلفية المعروضة حاليا في القائمة، حيث تمثل القيمة 0 العنصر الأول. bgGameObject سنستخدم هذا المتغير للوصول إلى الكائن الفارغ Background والذي خصصناه ليكون أبا لجميع صور الخلفية الموجودة في المشهد. الجدير بالذكر هنا هو أننا في معظم الحالات نستخدم متغيرا من نوع Transform للوصول للكائنات، ولذلك فوائد عديدة سنعرفها. عند بداية التشغيل يتم استدعاء الدّالة ()Start والتي تقوم أولا بالبحث في الأبناء عن الكائن المسمى Background وتخزينه في المتغير bgGameObject. الخطوة الثانية التي تقوم بها هذه الدّالة هي اختيار العنصر الأول في القائمة تلقائيا ليكون خلفية المشهد وذلك عن طريق استدعاء الدّالة ()ChangeBackground مع القيمة 0. الدّالة ()ChangeBackground هي الأهم في هذا البريمج حيث تعمل على تغيير الخلفية حسب القيمة التي تعطى لها حين الاستدعاء newIndex. إذا كانت هذه القيمة أصلا تساوي قيمة الخلفية المعروضة حاليا selectedIndex فلا حاجة لتغيير أي شيء بالتالي يتوقف تنفيذ الدّالة. عدا ذلك فإن الدّالة تحتاط لوجود خلفية معروضة حاليا فتقوم بحذفها قبل كل شيء. عملية الحذف تتم عبر المرور على جميع أبناء الكائن bgGameObject واستدعاء الدّالة ()Destroy لها وذلك ليتم حذفها من المشهد. حالة حذف الكائن هي أحد الاستثناءات لقاعدة التعامل مع الكائنات عن طريق المكون Transform، حيث أن الحذف يجب أن يتم على مستوى الكائن كاملا وليس على مكوّن بعينه، بالتالي نستدعي bgSprite.gameObject حين الحذف. بعد حذف الخلفية السابقة (إن وجدت) نحتاج لإضافة الجديدة. بالتالي فالخطوة التالية ستكون تخزين قيمة الصورة الموجودة في العنصر newIndex في المتغير newSprite ومن ثم حساب عرض الصورة الأصلية بوحدات فضاء Unity وذلك بقسمة عرضها بوحدة البكسل على عدد البكسلات في كل وحدة. بما أن تكرار صورة الخلفية سوف يتمحور حول نقطة الأصل، فإن أقصى نقطة إلى اليسار ستكون نصف عرض جميع الصور التي سنكررها، بالتالي نحسب القيمة posX عن طريق ضرب نصف العدد الكلي للصور repeatCount بعرض الصورة الواحدة width، ونحول الناتج للقيمة السالبة حتى نبدأ من اليسار (تذكر أننا قمنا باختيار يسار الصورة ليكون نقطة ارتكازها في الفضاء). إضافة لنقطة البداية قمنا بحساب الطول والعرض الكلي للمشهد بعد بناء الخلفية الجديدة، ومن ثم قمنا بتخزين هذه القيم في متغير من نوع Bounds، هذا المتغير يخزن ببساطة نقطة في الفضاء وامتدادات لهذه النقطة على المحورين x و y. لاحظ أننا عرّفنا متغير الامتداد على شكل متجه ثنائي الأبعاد وحددنا قيمة x بأنها عدد مرات تكرار صورة الخلفية مضروبة في عرض الصورة الواحدة و قيمة y على أنها ارتفاع الصورة الواحدة. بعد ذلك قمنا بإرسال رسالة لجميع البريمجات الأخرى سواء الموجودة على الكائن الجذري أو أي كائن في الأبناء وذلك لإعلامها بأن حدود المشهد تغيرت، واستخدمنا لذلك الدّالة ()BroadcastMessage. عند استدعاء هذه الدّالة فإننا نعطيها اسم الرسالة وهو هنا SceneBoundsChanged ونرفق معها أيضا المتغير newBounds الذي يحتوي على حدود المشهد الجديدة. سنرى لاحقا ما معنى أن ترسل رسالة، وكيف تستقبلها، ولماذا هي مهمة. خلال مجموعة الدروس هذه سنتعامل بشكل مستفيض مع الرسائل، لذا لا بأس من التعرف مبكرا على كيفية إرسالها. يتم إرسال الرسائل في محرك Unity باستخدام أحد الدّوال التالية: BroadcastMessage والتي تقوم بإرسال رسالة لجميع البريمجات الموجودة على الكائن الحالي إضافة إلى جميع كائنات الأبناء والأحفاد المتفرعة منه. SendMessage والتي تقوم بإرسال الرسالة إلى جميع البريمجات الموجودة على الكائن الحالي فقط. SendMessageUpwards والتي تقوم بإرسال الرسالة إلى جميع بريمجات الكائن الحالي إضافة إلى جميع آبائه وأجداده في الهرمية. الحلقة التكرارية التي تلي هذه الخطوات هي التي يتم عبرها بناء وتوزيع صور الخلفية في الفضاء بالشكل الصحيح. حيث نبدأ بإنشاء كائن جديد bg مستخدمين القالب bgElementPrefab ثم نعطيه اسما متسلسلا حسب موقعه في مجموعة الخلفيات من اليسار لليمين (مثلا BG_0 سيكون اسم الخلفية في أقصى اليسار و BG_3 في أقصى اليمين). بعد ذلك نستدعي المكوّن Sprite Renderer ونحدد له الصورة التي سيقوم بتصييرها، وهي هنا newSprite التي سبق واستخرجناها من المصفوفة selectionList. مرة أخرى تعاملنا مع الكائن مباشرة وليس مع مكون Transform، وذلك لأن الهدف هنا هو إضافة مكون جديد له. أما الخطوتان التاليتان فهما تتعاملان مع bg.transform كما ترى، فالأولى تحدد موقعه في الفضاء gb.transform.position والثانية تحدد الكائن الأب له وهو هنا bgGameObject الذي سبق وتحدثنا عنه. بالنسبة للموقع فإن الخلفية تتوسط الشاشة عموديا كما ترى حيث y=0، أما أفقيا فالموقع هو القيمة posX التي سبق وحسبناها. من المهم هنا إدراك حقيقة أن صور الخلفية يجب أن تظهر خلف باقي الصور الموجودة في فضاء اللعبة. من أجل ذلك علينا أن نخبر مكوّن التصيير أن يقوم برسم الخلفية قبل أن يرسم أي صورة أخرى وذلك حتى لا تغطي الخلفية على عناصر المشهد. هذا الأمر يمكننا القيام به عن طريق تقليل قيمة المتغير sr.sortingOrder حيث القيمة الافتراضية هي صفر. الصور ذات القيمة الأقل للمتغير sortingOrder يتم تصييرها أولا تتبعها الصور ذات القيمة الأعلى فالأعلى. بعد ذلك نضيف مكوّن اكتشاف التصادمات Box Collider 2D حتى نتمكن من اكتشاف قيام اللاعب بلمس الخلفية أو النقر عليها بالفأرة. الخطوة الأخيرة هي زيادة posX بمقدار عرض الصورة width حتى يكون موقع الصورة التالية على يمين الحالية مباشرة وملاصقة لطرفها الأيمن. بعد بناء الخلفية نرسل رسالة أخرى هي BackgroundChanged ونرفق معها رقم عنصر الخلفية الجديدة، والتي سنرى لاحقا أهميتها. بناء نظام التحكم بالكاميرا لو قمت بتشغيل اللعبة ستلاحظ أن الكاميرا تنظر مباشرة لوسط المشهد، وما سنقوم به الآن هو إضافة آلية لتحريكها يمينا ويسارا، إضافة إلى إمكانية التقريب والإبعاد. قبل البدء في تفاصيل التحكم بالكاميرا علينا أن نحدد ما هي نسبة عرض الشاشة إلى طولها aspect ratio. هذه القيمة مهمة لأنها تؤثر مباشرة على حجم المستطيل الذي يحدد مجال رؤية الكاميرا. بما أن هذه اللعبة مصممة للهواتف وتلعب بشكل عرضي، فإن النسبة التي سنعتمدها هي 16:9، ولاختيارها انتقل من شاشة المشهد Scene إلى شاشة اللعبة Game وقم باختيار هذه النسبة من أعلى يسار الشاشة كما ترى هنا: قبل البدء في كتابة بريمج التحكم بالكاميرا، علينا أن نعرف طبيعة هذا التحكم وماذا يمكن للاعب أن يفعل من خلاله. طريقة التحكم ستكون ثنائية البعد، أي أن اللاعب سيتمكن من نحريك الكاميرا يمينا ويسارا ولأعلى وأسفل. إضافة لذلك فإن اللاعب يجب أن يكون قادرا على تقريب وإبعاد الكاميرا، وكل ذلك ضمن حدود معينة. سنطّلع بعد قليل على بريمج التحكم بالكاميرا ومن ثم نشرح بعض تفاصيله، وأخيرا سنرى كيف يمكننا ربط وظائف هذا البريمج مع مدخلات اللاعب. لكن قبل ذلك لنتعلم أحد المبادئ المهمة في برمجة الألعاب وخاصة مع محرك Unity وهي فصل الاهتمامات separation of concerns. معنى هذا أن البريمج الواحد مسؤول عن مهمة واحدة، وهو الوحيد المخول بالتحكم بتفاصيل هذه المهمة. بالتالي فإننا سنقوم أولا بكتابة بريمج للتحكم بالكاميرا، لكنه منفصل تماما عن مدخلات اللاعب. هذه المدخلات سنكتبها في بريمج آخر خاص بها، والذي سيستقبلها وبناء عليها "يطلب" من بريمج التحكم بالكاميرا تحريكها. هذا الطلب قد يستجاب له وقد لا يستجاب له وفقا لمعطيات يقدرها بريمج التحكم بالكاميرا. مثلا إذا حرك اللاعب الكاميرا لأقصى يمين المشهد ومن ثم حاول تحريكها لليمين متجاوزا حدود المشهد، فسيقوم بريمج الكاميرا برفض هذا الطلب وستبقى الكاميرا في مكانها. لنبدأ الآن مع البريمج CameraControl والذي تراه في السرد التالي، هذا البريمج يجب أن تتم إضافته إلى كائن الكاميرا حتى يقوم بالتحكم بها: using UnityEngine; using System.Collections; public class CameraControl : MonoBehaviour { //أقل حجم للكاميرا في حال التقريب public float minSize = 2.5f; //ارتفاع المستطيل المحدد لمجال الرؤية private float camHeight; //عرض المستطيل المحدد لمجال الرؤية private float camWidth; //مرجع إلى مكوّن الكاميرا Camera cam; //متغير لتخزين حدود المشهد الحالية private Bounds sceneBounds; //يتم تنفيذها مرة عند بداية التشغيل void Start () { //اجلب مكوّن الكاميرا الموجود على نفس الكائن cam = GetComponent<Camera>(); //قم بتحديث أبعاد الكاميرا UpdateCamDimensions(); } //يتم استدعاؤها مرة عند تصيير كل إطار void Update () { } //قم باستقبال الرسالة التي تخبر بتغير حدود المشهد void SceneBoundsChanged(Bounds newBounds){ sceneBounds = newBounds; } //تقوم هذه الدّالة بتحريك الكاميرا بمقدار محدد public void Move(Vector2 distance){ //قم بحساب حدود الحركة المسموحة float maxX = GetRightLimit(); float minX = GetLeftLimit(); float maxY = GetUpperLimit(); float minY = GetLowerLimit(); //يهمنا في هذه الحالة الخاصّة الموقع ثلاثي الأبعاد //z = -10 وذلك حتى نضمن أن الكاميرا في الموقع //دائما Vector3 dist3D = distance; //احسب الموقع الجديد Vector3 newPos = transform.position + dist3D; //التزم بحدود الحركة newPos.x = Mathf.Clamp(newPos.x, minX, maxX); newPos.y = Mathf.Clamp(newPos.y, minY, maxY); newPos.z = -10.0f; //قم بتغيير الموقع transform.position = newPos; } //تقوم هذه الدّالة بتغيير تقريب الكاميرا //القيمة الموجبة تعني تقريب الكاميرا من المشهد //القيمة السالبة تعني إبعاد الكاميرا عن المشهد public void Zoom(float amount){ float newSize = cam.orthographicSize - amount; //التزم بحدود التقريب والإبعاد float maxSize = GetMaxSize(); newSize = Mathf.Clamp (newSize, minSize, maxSize); //قم بتغيير حجم مجال الرؤية وبالتالي يتغير التقريب cam.orthographicSize = newSize; //قم بتحريك الكاميرا بمقدار صفر حتى تضمن تنفيذ الالتزام بحدود المشهد UpdateCamDimensions(); Move(Vector2.zero); } //تقوم بتحديث عرض وارتفاع الكاميرا void UpdateCamDimensions(){ camHeight = cam.orthographicSize * 2; camWidth = camHeight * cam.aspect; } //تحسب قيمة أعلى ارتفاع للكاميرا float GetUpperLimit(){ return sceneBounds.max.y - camHeight * 0.5f; } //تحسب قيمة أدنى ارتفاع للكاميرا float GetLowerLimit(){ return sceneBounds.min.y + camHeight * 0.5f; } //تحسب الحد الأقصى لموقع الكاميرا يمينا float GetRightLimit(){ return sceneBounds.max.x - camWidth * 0.5f; } //تحسب الحد الأقصى لموقع الكاميرا يسارا float GetLeftLimit(){ return sceneBounds.min.x + camWidth * 0.5f; } //تقوم بحساب أكبر حجم مسموح للكاميرا حين الإبعاد //وذلك اعتمادا على حدود المشهد float GetMaxSize(){ float maxHeight = sceneBounds.max.y - sceneBounds.min.y; return maxHeight * 0.5f; } } المتغير العام الوحيد في هذا البريمج هو minSize والذي يحدد أقل حجم مسموح للكاميرا عند التقريب. لشرح آلية التقريب والإبعاد لاحظ أولا أن الكاميرا لا ترى كل المشهد في كل وقت ولكن لها مجال رؤية محدد بشكل مستطيل، ويظهر هذا المستطيل حين اختيار الكاميرا كما في الشكل التالي: كلما زاد حجم هذا المستطيل فإن المساحة التي يراها من المشهد تزيد، بالتالي يبدو الأمر وكأن الكاميرا تبتعد، أما في حالة تقليل حجمه فإن العكس يحصل وهو تقليل المساحة المرئية بحيث يبدو الأمر كأن الكاميرا تقترب. يمكنك تجربة ذلك أثناء تشغيل اللعبة عن طريق تعديل قيمة المتغير size في مكوّن الكاميرا من شاشة الخصائص حيث قيمته الافتراضية هي 5. معنى هذا أننا نسمح بتقريب الكاميرا أي تصغير حجمها إلى أن يصل إلى 2.5 ويمكنك السماح بتقريبها أكثر إن قمت بتقليل هذا الرقم. المتغيران الآخران المهمان هما camHeight و camWidth ويمثلان طول وعرض المستطيل الذي يحدد مجال الرؤية، لكنهما محسوبان بقيم فضاء Unity وسنرى أهميتهما بعد قليل. أيضا نحتاج لمرجع لمكوّن الكاميرا وهو cam. تذكر ما ذكرته سابقا وهو أن المكوّنات هي التي تميز كائنا عن الآخر، ووجود هذا المكوّن هو الذي يجعل الكائن المسمى Main Camera عبارة عن كاميرا وليس شيئا آخر. أخيرا لدينا متغير من نوع Bounds وهو sceneBounds والذي سيقوم بتخزين حدود المشهد والتي سنحصر حركة الكاميرا دخلها. سأبدأ هذه المرة مع الدّالة ()SceneBoundsChanged وذلك لإكمال شرح فكرة الرسائل. بالعودة إلى البريمج BackgroundManager سترى أنه يقوم بإرسال رسالة اسمها SceneBoundsChanged ويرفقها بمتغير من نوع Bounds. إذا قمت بتعريف دالّة تحمل نفس اسم الرسالة وتأخذ متغيرا من نفس نوع المرفق، فإنك بذلك حددت هذه الدّالة كأحد مستقبلي هذه الرسالة (يمكن أن يستقبل الرسالة عدد غير محدد من البريمجات). بالتالي فالبريمج CameraControl يستقبل هذه الرسالة ويخزن قيمة الحدود الجديدة للمشهد في المتغير sceneBounds. بالعودة إلى ()Start فإن ما تقوم به هو تخزين قيمة مكوّن الكاميرا في المتغير cam ومن ثم تقوم باستدعاء ()UpdateCamDimensions والتي تحسب عرض وارتفاع الكاميرا بوحدات فضاء Unity. لنتحدث قليلا عن كيفية حساب هذه القيم. بالرجوع إلى توثيق محرك Unity وتحديدا هذه الصفحة سنعرف أن قيمة المتغير cam.orthographicSize والذي يمثل حجم الكاميرا هو عبارة عن منتصف ارتفاعها. بالتالي يمكننا ضرب هذه القيمة في 2 لنحصل على ارتفاع الكاميرا. بالنسبة لعرضها فهو يعتمد على نسبة العرض إلى الارتفاع aspect ratio، والذي يمكننا الوصول إليه عبر المتغير cam.aspect، حيث يمكننا ضرب هذا المتغير في الارتفاع لنحصل على عرض الكاميرا. بحصولنا على حدود المشهد وارتفاع الكاميرا وعرضها، يمكننا أن نحسب الحدود التي يُسمح للكاميرا بالحركة ضمنها. سيُسمح للكاميرا بالحركة يمينا إلى أن يصل طرف مستطيل مجال الرؤية الأيمن إلى أقصى حد في يمين المشهد. بالتالي فإن أقصى موقع للكاميرا يمينا هو طرف المشهد الأيمن مطروح منه نصف عرض الكاميرا. هذه القيمة نحسبها عن طريق الدّالة ()GetRightLimit، وبطريقة مشابهة نحسب الحدود الأخرى في اليسار والأعلى والأسفل عبر ()GetLeftLimit و ()GetUpperLimit و ()GetLowerLimit. لاحظ أننا في هذه الدّوال استخدمنا sceneBounds.min و sceneBounds.max وهما المتجهان اللذان يعبران عن الحد الأدنى (أسفل ويسارا) والحد الأقصى (أعلى ويمينا) لحدود المشهد. قبل الدخول في الوظائف الرئيسية للبريمج وهما التحريك والتقريب، لنتعرف على الدّالة المتبقية ضمن الدّوال المساعدة وهي ()GetMaxSize. تعمل هذه الدّالة على حساب الحد الأقصى المسموح لحجم الكاميرا حين تنفيذ الإبعاد، وذلك اعتمادا على الحدين العلوي والسفلي للمشهد إضافة لارتفاع الكاميرا. فالارتفاع الأقصى المسموح به هو المسافة بين حد المشهد الأعلى والأسفل، والحجم المسموح به بالتالي هو نصف هذه المسافة، ذلك لأن حجم الكاميرا هو نصف ارتفاعها كما سبق وعرفنا. نأتي الآن على الوظائف الأساسية لهذا البريمج وهي تحريك الكاميرا وتقريبها وإبعادها. البداية مع التحريك والذي يتم عن طريق الدّالة ()Move والتي تأخذ متجها يمثل مقدار الإزاحة التي نرغب بتحريك الكاميرا بها. أول خطوة هي حساب القيم القصوى والدنيا للحركة المسموح بها، وتخزينها في المتغيرات الأربع maxX, minX, maxY, minY. بعد ذلك نقوم بتعريف متجه ثلاثي الأبعاد هو dist3D وذلك لأن موقع الكاميرا على محور الثالث z مهم بخلاف الكائنات الأخرى، حيث يجب أن تبقى هذه القيمة سالبة أي أن الكاميرا بعيدة عن المشهد نحو الخارج وتنظر إليه داخل الشاشة. أما إذا حسبنا موقعها مستخدمين الأبعاد الثنائية فقط فإن هذا سيؤدي لضياع القيمة z للموقع وتصبح الكاميرا في الموقع z=0 وبالتالي لن يمكنها رؤية أي كائن في المشهد. بعد حساب الموقع الجديد نقوم بتخزينه في الموقع newPosition وكل ما علينا الآن هو التأكد من أن قيمة newPos.x محصورة بين minX و maxX وكذلك الأمر بالنسبة لقيمة newPos.y التي يجب أن تنحصر بين minY و maxY. لاحظ هنا استخدام الدّالة ()Mathf.Clamp والتي تقوم بالتأكد من أن الرقم الأول محصور بين الرقمين الثاني والثالث وتعيد الحد الأقصى أو الأدنى في حال كان الرقم يتجاوز هذين الحدين. أخيرا نقوم بتغيير موقع الكاميرا للموقع الجديد newPos. الوظيفة الرئيسية الثانية هي تقريب الكاميرا وإبعادها، والتي تتم عن طريق الدّالة ()Zoom، هذه الدّالة تأخذ رقما واحدا وهو مقدار التقريب (إن كان موجبا) أو الإبعاد (إن كان سالبا). طريقة التقريب والإبعاد تتم عن طريق طرح القيمة amount من حجم الكاميرا الحالي، وذلك لأن العلاقة بين حجم الكاميرا وتقريبها عكسية، فزيادة الحجم تعني تقليل التقريب. بعدها يتم حصر القيمة الجديدة بين minSize وهو المتغير الذي يمكننا تحديده كما نشاء و maxSize والذي يتم حسابه عن طريق الدّالة ()GetMaxSize التي سبق شرحها. بعد التأكد من أن الحجم الجديد ضمن الحدود يتم تغيير قيمة cam.orthogonalSize للقيمة الجديدة newSize. أخيرا يجب أن نعيد حساب أبعاد الكاميرا حيث أن حجمها تغير لذا نستدعي ()UpdateCamDimensions إضافة إلى إزاحتها بمقدار صفر احتياطا من إمكانية كون تغيير حجمها قد أدى لتجاوز حدودها لحدود المشهد خاصة في حالة الإبعاد حيث يزيد حجم الكاميرا؛ حيث أن الإزاحة بغض النظر عن مقدارها ستُبقي الكاميرا ضمن حدود المشهد دائما. بهذا تكون آلية التحكم بالكاميرا جاهزة، ويبقى علينا أن نربط استدعاء كل من ()Move و ()Zoom بمدخلات اللاعب. البداية ستكون مع الفأرة وذلك لأن المحرك يعمل على الحاسب بالتالي من السهل تجربتها مباشرة، بينما سنؤجل شاشة اللمس والهواتف الذكية لوقت لاحق. تحريك الكاميرا سيتم عن طريق الضغط بزر الفأرة الأيسر على الخلفية ومن ثم تحريكها في الاتجاهات الأربع، بينما يستخدم الزر الأيمن للتقريب والإبعاد، حيث يضغط اللاعب بالزر الأيمن ويحرك الفأرة لليمين للتقريب ولليسار للإبعاد. هذه الوظائف سيقوم بها البريمج CameraMouseInput والذي يجب أن نضيفه لقالب الخلفية BGElement، حيث يجب أن تكون كل صور الخلفية قادرة على استقبال مدخلات الفأرة. فيما يلي سرد بهذا البريمج. using UnityEngine; using System.Collections; public class CameraMouseInput : MonoBehaviour { //سرعة حركة الكاميرا public float movementSpeed = 0.1f; //سرعة التقريب والإبعاد public float zoomingSpeed = 0.01f; //أثناء التقريب والإبعاد trueتكون قيمة هذا المتغير private bool zoomingAcitve = false; //تقوم بتخزين موقع مؤشر الفأرة في الإطار السابق private Vector2 lastPosition; //مرجع لبريمج التحكم بالكاميرا private CameraControl camControl; //يتم تنفيذ هذه الدّالة مرة عند بداية التشغيل void Start () { //اجلب بريمج التحكم بالكاميرا camControl = FindObjectOfType<CameraControl>(); } //يتم استدعاؤها عند تصيير كل إطار لكن في وقت متأخر void LateUpdate () { UpdateZooming(); } //تقوم بقراءة مدخلات التقريب والإبعاد void UpdateZooming(){ //قم بفحص الضغط على زر الفأرة الأيمن if(Input.GetMouseButtonDown(1)){ zoomingAcitve = true; lastPosition = Input.mousePosition; } //قم بتحديث التقريب والإبعاد بناء على الحركة الأفقية للفأرة if(zoomingAcitve){ float amount = Input.mousePosition.x - lastPosition.x; camControl.Zoom(amount * zoomingSpeed); lastPosition = Input.mousePosition; } //عند رفع الضغط عن زر الفأرة الأيمن false إلى zoomingActive قم بإرجاع قيمة if(Input.GetMouseButtonUp(1)){ zoomingAcitve = false; } } //يتم استدعاء هذه الدّالة عند الضغط بزر الفأرة الأيمن على الكائن void OnMouseDown(){ //لا تسمح بالحركة أثناء التقريب والإبعاد if(!zoomingAcitve){ lastPosition = Input.mousePosition; } } //يتم استدعاء هذه الدّالة عند السحب بزر الفأرة الأيسر فوق الكائن void OnMouseDrag(){ //امنع الحركة أثناء التقريب والإبعاد if(!zoomingAcitve){ Vector2 movement = lastPosition - (Vector2)Input.mousePosition; camControl.Move(movement * movementSpeed); lastPosition = Input.mousePosition; } } } كما ذكرت سابقا يجب أن تتم إضافة هذا البريمج على قالب صور الخلفية، وذلك لأن اللاعب سيضغط عليها بالفأرة إذا أراد تحريك الكاميرا. عند بداية تشغيل البريمج فإنه يبحث في المشهد عن البريمج الذي يتحكم بالكاميرا CameraController وذلك عن طريق ()FindObjectOfType، وبما أن هذا البريمج موجود مرة واحدة في المشهد بالتالي ستكون نتيجة البحث هي قطعا البريمج الموجود على الكاميرا. بالتالي يمكننا الآن استخدام المتغير camController للتحكم بالكاميرا. عندما يضغط اللاعب بزر الفأرة الأيسر على كائن ما يتم إرسال الرسالة OnMouseDown مرة واحدة إلى ذلك الكائن، لذا فإننا هنا نستقبل هذه الرسالة عبر الدّالة التي تحمل نفس الاسم حيث نقوم بتخزين موقع الفأرة الحالي في المتغير lastPosition. إذا بقي اللاعب ضاغطا على الزر الأيسر وقام بتحريك الفأرة، فحينها يتم إرسال الرسالة OnMouseDrag إلى الكائن في كل إطار يتم فيه تحريك المؤشر أثناء الضغط. في الدّالة ()OnMouseDrag نقوم بثلاثة أشياء: أولا نحسب مقدار الإزاحة عن موقع الضغط وذلك بطرح الموقع الحالي من الموقع السابق. بهذه الطريقة فإننا نجعل حركة الكاميرا معاكسة لحركة المؤشر، فإن تحرك المؤشر يمينا تتحرك الكاميرا يسارا، مما يعطي اللاعب شعورا بأنه لا يحرك الكاميرا، وإنما يمسك المشهد ويحركه يمينا ويسارا وهذه الطريقة أسهل عليه في التحكم خاصة مع شاشة اللمس. الخطوة الثانية هي استدعاء الدّالة ()Move وتزويدها بمقدار الإزاحة المحسوبة مضروبة في سرعة الحركة. لاحظ أن سرعة الحركة قليلة نسبيا وذلك لأن قيمة إزاحة الفأرة عالية مقارنة مع مقدار الإزاحة المطلوبة للكاميرا. أخيرا وبعد تنفيذ الإزاحة نقوم بتخزين موقع المؤشر الحالي في lastPosition حتى نكون جاهزين لحساب الإزاحة القادمة انطلاقا من الموقع الجديد. لاحظ أن جميع الخطوات في الدّالتين ()OnMouseDown و ()OnMouseDrag مشروطتان بأن تكون قيمة zoomingActive هي false. هذا المتغير يخبرنا ما إذا كان اللاعب يقوم حاليا بتقريب أو إبعاد الكاميرا مستخدما الزر الأيمن، وبذلك نمنع التحريك والتقريب في آن واحد. الدّالة ()LateUpdate التي عرّفناها هنا بدلا من ()Update تختلف عن هذه الأخيرة في شيء واحد، وهو أنها تُستدعى متأخرة عنها. فعند تحديث كل إطار يقوم Unity بتحديث جميع البريمجات عن طريق استدعاء ()Update ومن ثم يعاود التحديث مجددا باستخدام ()LateUpdate. بالتالي فإن استدعاء أي خطوات خلال ()LateUpdate هو مضمون أن يتم تنفيذه بعد تحديث جميع عناصر المشهد من خلال ()Update. الفائدة من ذلك هو أن نضمن أن كل العناصر أصبحت جاهزة للإطار الجديد قبل أن نقوم بتحريك الكاميرا، بالتالي عادة ما يتم استخدام ()LateUpdate مع أي شيء له علاقة بالتحكم بالكاميرا. ما نفعله في ()LateUpdate هو استدعاء ()UpdateZooming والتي تفحص ما إذا ضغط اللاعب على زر الفأرة الأيمن عن طريق الدّالة ()Input.GetMouseButtonDown والتي تفحص أزرار الفأرة بناء على أرقامها؛ فالرقم 0 للأيسر و 1 للأيمن و 2 للأوسط. أول ما نقوم به عند اكتشاف الضغط على الزر الأيمن هو تغيير قيمة zoomingActive إلى true مما يمنع استقبال مدخلات التحريك. بعدها نقوم بتخزين الموقع الحالي للمؤشر في lastPosition تماما كما فعلنا مع حالة الضغط بالزر الأيسر. بناء على قيمة zoomingActive نقوم بتحديث التقريب والإبعاد، فنحسب قيمة الإزاحة الأفقية بطرح الإحداثي x للموقع السابق من الإحداثي x للموقع الحالي، فإذا تحرك مؤشر الفأرة يمينا ستكون النتيجة موجبة مما يجعل قيمة amount * zoomingSpeed المزودة للدّالة ()camControl.Zoom موجبة وينتج عنه تقريب الكاميرا، والعكس يحدث حين تحريك الفأرة يسارا. بعد تحديث قيمة التقريب نقوم بتخزين الموقع الحالي في lastPosition من أجل حساب الإزاحة القادمة. آخر خطوة في هذه الدّالة هي فحص ما إذا كان اللاعب قد رفع الضغط عن زر الفأرة الأيمن وإعادة قيمة zoomingSpeed إلى false في هذه الحالة. بناء أرضية المشهد بعد أن انتهينا من تجهيز خلفية المشهد وأصبحنا قادرين على تحريك الكاميرا للتجول فيه بحرية، حان الوقت لإضافة عناصر المشهد، وهي الكائنات ثنائية الأبعاد التي سنبني منها اللعبة. ستكون البداية مع الأرضية التي ستقف عليها باقي الكائنات، والتي سنبنيها بطريقة مشابهة لطريقة بناء الخلفية. سنقوم أولا بإضافة كائن فارغ كابن للكائن الجذري ونسميه Ground. هذا الكائن سيكون أبا لجميع كائنات الأرضية. بعدها سنقوم بإنشاء قالب خاص بكائنات الأرضية ونضيف له المكوّن sprite renderer والبريمج CameraMouseInput، مما يجعل التحكم بالكاميرا عن طريق الضغط على الأرضية ممكنا تماما كما هو الحال مع الخلفية. سنسمي هذا القالب GroundElement ومن ثم سنكتب البريمج الذي يعمل على إضافة كائنات الأرضية اعتمادا على الخلفية وحدود المشهد. هذا البريمج هو GroundManager والموضح في السرد التالي، علما أن هذا البريمج يجب أن نضيفه أيضا للكائن الجذري SceneRoot. using UnityEngine; using System.Collections; public class GroundManager : MonoBehaviour { //قالب كائن عناصر الأرضية public GameObject gePrefab; //مصفوفة بصور الأرضية المتوفرة public Sprite[] selectionList; //متغير لتخزين حدود المشهد الحالية private Bounds sceneBounds; //موقع صورة الخلفية المعروضة حاليا private float selectedIndex = -1; //تستدعى مرة واحدة عند بداية التشغيل void Start () { } //تستدعى مرة واحدة عند تصيير كل إطار void Update () { } //تقوم باستقبال رسالة تغير صورة الخلفية void BackgroundChanged(int newIndex){ ChangeGround(newIndex); } //تقوم باستقبال رسالة تغير حدود المشهد void SceneBoundsChanged(Bounds newBounds){ sceneBounds = newBounds; } //تقوم بتغيير صورة الأرضية المعروضة بناء على رقم العنصر المحدد public void ChangeGround(int newIndex){ //"Ground" ابحث عن الكائن الابن المسمى Transform groundGameObject = transform.FindChild("Ground"); //قم بحذف عناصر الأرضية الحالية for(int i = 0; i < groundGameObject.childCount; i++){ Transform ge = groundGameObject.GetChild(i); Destroy(ge.gameObject); } //قم بحساب عرض المشهد float sceneWidth = sceneBounds.max.x - sceneBounds.min.x; //قم بحساب النقطة في أقصى اليسار float posX = sceneBounds.min.x; //قم بجلب صورة الأرضية من المصفوفة Sprite newGround = selectionList[newIndex]; //قم بحساب عرض وارتفاع صورة الأرضية float width = newGround.rect.width; float height = newGround.rect.height; //قم بحساب العرض والارتفاع بالوحدات width = width / newGround.pixelsPerUnit; height = height / newGround.pixelsPerUnit; //قم بحساب الموقع العمودي للأرضية float posY = sceneBounds.min.y + height * 0.5f; //كم مرة يجب أن نكرر صورة الأرضية؟ int repeats = Mathf.RoundToInt(sceneWidth / width) + 1; for(int i = 0; i < repeats; i++){ //قم بإضافة كائن عنصر أرضية جديد GameObject ge = (GameObject) Instantiate(gePrefab); ge.name = "GE_" + i; //ضع الكائن في الموقع الصحيح ge.transform.position = new Vector2(posX, posY); //قم بتحديد الأب ge.transform.parent = groundGameObject; //قم بوضع الصورة على مكوّن تصيير الكائن الذي أضفناه SpriteRenderer sr = ge.GetComponent<SpriteRenderer>(); sr.sprite = newGround; //قم بإضافة مكوّن التصادم ge.AddComponent<BoxCollider2D>(); //قم بزيادة قيمة الموقع الأفقي استعدادا للعنصر التالي posX += width; } } } ما نلاحظه للوهلة الأولى هو التشابه الشديد بين GroundManager و BackgroundManager حثي أن كلا منهما يقوم ببناء عدة كائنات مستخدما قالبا محددا ومن ثم رص هذه الكائنات بجانب بعضها البعض من اليسار لليمين. الفرق هنا هو أن هذا البريمج يعتمد في عمله على استقبال الرسالة BackgroundChanged بحيث يختار صورة الأرضية الموجودة في الموقع المساوي لموقع الخلفية التي تم اختيارها. معنى ذلك أنه لصور الخلفية الثمانية الموجودة سنحتاج لصور ثماني أرضيات. صور الأرضيات موجودة في المجلد Other في مجموعة الصور وعددها هو 4 كما ترى في الصورة التالية، مما يعني أن نفس الأرضية يمكن أن تتكرر مع أكثر من خلفية: أول خطوة علينا القيام بها هي تغيير نقطة الارتكاز لهذه الصور الأربعة بحيث تصبح في اليسار تماما كما فعلنا مع صور الخلفية. ومن ثم سنقوم بسحبها إلى المواقع الثمانية داخل المصفوفة selectionList. أثناء إضافتي لصورة الأرضية المقابلة لكل خلفية، حاولت أن أختار لونا متميزا عن لون الجزء الأسفل من الخلفية، بحيث يسهل على اللاعب تمييز أرضية المشهد عن خلفيته، وكانت النتيجة كالتالي. لا تنس أيضا بعد إضافة البريمج للكائن SceneRoot أن تقوم بتحديد القالب GroundElement ليكون هو مصدر بناء كائنات الأرضية. اختلاف آخر بين البريمجين نلحظه في موقع وضع الكائنات، حيث توضع كائنات الخلفية عموديا في المنتصف بينما توضع كائنات الأرضية في الأسفل. الفرق الآخر المهم هو أن عدد مرات التكرار بالنسبة للخلفية متغير يمكننا تحديد قيمته، بينما في حالة الأرضية يتم حسابه تلقائيا بقسمة عرض المشهد على عرض صورة الأرضية، ومن ثم إضافة 1 لتغطية أي ثغرة متبقية في أقصى اليمين نتيجة للكسور. إلى هذه النقطة نكون قد انتهينا من بناء الخلفية والأرضية، بالتالي لو حاولنا تشغيل اللعبة الآن سنحصل على النتيجة التالية: قمنا في هذا الدرس بتجهيز الخلفية و الأرضية للعبة كما قمنا بالتحكم في الكاميرا. في الدرس القادم سنقوم بإنشاء الوحدات البنائية للعبة. اقرأ أيضًا تعرف على أشهر لغات برمجة الألعاب
-
نبذة مختصرة عن صناعة الألعاب وتطويرها لعل صناعة الألعاب هي إحدى أصعب الصناعات في هذا العصر، وذلك من عدة نواح تبدأ بالتحديات التقنية، مرورا بجمهور صعب الإرضاء ولا يرحم حتى كبريات الشركات إن لم تكن منتجاتها بالمستوى المطلوب، وليس انتهاءا بالمنافسة الشرسة ونسب الفشل العالية وصعوبة تحقيق أرباح تغطي تكاليف الإنتاج المرتفعة. على الجانب الآخر يوجد ميزات لهذه الصناعة تجعل من النجاة فيها أمرا ممكنا، فعلى الجانب التقني مثلا، لا تخلوا الغالبية العظمى من الألعاب من وظائف متشابهة وأنماط متكررة من معالجة البيانات، مما يجعل إعادة استخدام الوحدات البرمجية للألعاب السابقة من أجل إنشاء ألعاب جديدة أمرا ممكنا، وهذا بدوره يساهم في تذليل العقبات التقنية واختصار الوقت والجهد. عندما تتحدث عن صناعة لعبة، فأنت هنا تذكر العملية الكبرى والتي تنطوي على عشرات وربما مئات المهام التي يجب أن تنجزها في مجالات عدة. فصناعة لعبة تعني إنتاجها وتسويقها ونشرها وكل ما يتعلق بهذه العمليات من إجراءات وخطوات إدارية وتقنية وفنية ومالية وقانونية. على أية حال فإن ما يهمنا في سلسلة الدروس هذه هو الجانب التقني وهو تطوير اللعبة، وهي عملية بناء المنتج البرمجي النهائي بكافة مكوّناته. هذه العملية لا تشمل بالضرورة تصميم اللعبة، حيث أن عملية التصميم ذات منظور أوسع وتركز على أمور مثل القصة والسمة العامة للعبة وأشكال المراحل وطبيعة الخصوم، بالإضافة إلى قواعد اللعبة وأهدافها وشروط الفوز والخسارة. بالعودة لعملية تطوير اللعبة، نجد أن العديد من الاختصاصات والمهارات تساهم في هذه العملية، فهناك الرسامون ومصممو النماذج وفنيو التحريك ومهندسو الصوت والمخرج، إضافة – بالطبع – للمبرمجين. هذه النظرة الشاملة مهمة لنعرف أن دور المبرمج في إنتاج اللعبة ليس سوى دورا مكمّلا لأدوار غيره من أعضاء الفريق، ولو أن هذه الصورة بدأت تتغير بظهور المطورين المستقلين Indie Developers والذين يقومون بالعديد من المهام إلى جانب البرمجة. لماذا نستخدم محركات الألعاب؟ لو أردنا الحديث بتفصيل أكبر عن دور المبرمجين في صناعة الألعاب، سنجد أنه حتى على مستوى البرمجة نفسها هناك أدوار عديدة يجب القيام بها: فهناك برمجة الرسومات وهناك أنظم الإدخال وأنظمة استيراد الموارد والذكاء الاصطناعي ومحاكاة الفيزياء وغيرها مثل مكتبات الصوت والأدوات المساعدة. كل هذه المهام يمكن إنجازها على شكل وحدات برمجية قابلة لإعادة الاستخدام كما سبق وذكرت، وبالتالي فهذه الوحدات تشكل معا ما يعرف بمحرك الألعاب Game Engine. باستخدامك للمحرك والمكتبات البرمجية التي يتكون منها، فأنت تختصر على نفسك الجهد اللازم لبناء نظام الإدخال والإخراج والاستيراد ومحاكاة الفيزياء، وحتى جزء من الذكاء الاصطناعي. وما يتبقى عليك هو كتابة منطق لعبتك الخاصة وإبداع ما يميزها عن غيرها من الألعاب. هذه النقطة الأخيرة هي ما ستدور حوله سلسلة الدروس القادمة، وبالرغم من أن المهمة تبدو صغيرة جدا مقارنة بتطوير اللعبة كاملة، إلا أنها على صغرها تحتاج لمجهود معتبر في التصميم والتنفيذ كما سنرى. خطوات سريعة لتبدأ مع محرك Unity إن لم تكن ذا خبرة سابقة بهذا المحرك يمكنك قراءة هذه المقدمة السريعة، كما يمكنك تخطيها إن كنت تعاملت مع هذا المحرك سابقا. لن أطيل شرح هذه الخطوات حيث هناك الكثير من الدروس سواء بالعربية أو الإنجليزية تتناولها، لكنها هنا لنتأكد من أن كل قارئ للسلسلة على نفس الدرجة من المعرفة الأولية قبل البدء. الخطوة الأولى: تحميل وتنصيب المحرك لتنزيل الإصدار الأحدث من المحرك وهو 5 ادخل مباشرة إلى الموقع http://unity3d.com ومن ثم قم بتحميل النسخة المناسبة لنظام التشغيل الذي تستخدمه، علما بأن النسخة المجانية من المحرك ذات إمكانات كبيرة وهي تفي بالغرض بالنسبة لمشروعنا في سلسلة الدروس هذه. الخطوة الثانية: إنشاء المشروع بمجرد تشغيل المحرك بعد تنصيبه ستظهر لك شاشة البداية، قم بالضغط على New Project لتظهر لك شاشة كالتي تراها في الصورة أدناه. كل ما عليك هو اختيار النوع 2D ومن ثم اختيار اسم وموقع المشروع الجديد الذي ستقوم بإنشائه، ومن ثم الضغط على Create Project. الخطوة الثالثة: التعرف على نوافذ البرنامج الرئيسية تهمنا في البداية 4 نوافذ رئيسية في محرك Unity، وفيما يلي ملخص لوظائفها: نافذة المشهد Scene: وهي التي تستخدمها لبناء مشهد اللعبة وإضافة الكائنات المختلفة إليه وتوزيعها في الفضاء ثنائي الأبعاد. تحتوي هذه النافذة مبدئيا على كائن واحد وهو الكاميرا. هرمية المشهد Hierarchy: تحتوي على ترتيب شجري يحوي كافة الكائنات التي تمت إضافتها للمشهد ويساعدك في تنظيم العلاقات بينها، حيث أنه من الممكن أن تضيف كائنات كأبناء لكائنات أخرى بحيث يتأثر الكائن الابن بالكائن الأب كما سنرى. تحتوي هذه النافذة مبدئيا على كائن واحد وهو الكاميرا. مستعرض المشروع Project: يقوم بعرض جميع الملفات الموجودة داخل مجلد المشروع، سواء تلك التي تمت إضافتها للمشهد أم التي لم تُضف. يحتوي المشروع مبدئيا على مجلد واحد يسمى Assets، وسنضيف داخله كافة الملفات والمجلدات الأخرى. نافذة الخصائص Inspector: عند اختيار أي كائن من هرمية المشهد أو نافذة المشهد أو مستعرض المشروع، فإن خصائصه ستظهر في هذه النافذة ويمكنك تغييرها من هناك. استعرضنا في هذا الدرس ما يظهر من واجهة Unity3D للوهلة الأولى، مع مقدمة بسيطة حول صناعة الألعاب، سنشرع في الدروس القادمة في مشروع عملي نتعلم من خلاله كيفية صناعة لعبة كاملة حقيقية. فترقبوا!
- 12 تعليقات
-
- 14