هذا المقال هو بداية سلسلة صنع لعبة Platformer ثنائية الأبعاد 2D، في محرك Unity.
ستشبه اللعبة في النهاية معظم ألعاب ال Platformer الشهيرة مثل Mario و Rayman من ناحية أسلوب الحركة.
مع أنني لن أقوم بتصميم مراحل كاملة في اللعبة، لأن هذا الأمر يعود للمصممين، ولكننا سنتعلم كيفية استخدام الأدوات في المحرك لبناء المراحل، ليصبح لدينا أساس نستطيع الاعتماد عليه عند العمل مع مصممين لاحقًا.
ونفس الأمر ينطبق على جميع العناصر الأخرى في اللعبة، حيث سنركز على شرح بناء الأنظمة وتركيبها، ومن ثم بناء اللعبة كاملة يعود إلى المطور.
ستنقسم السلسلة إلى مقالات – وأول مقال سنتعلم فيه كيفية برمجة حركة اللاعب على المستوى الأفقي، وفي الذي يليه برمجة القفز والارتداد على الجدران وما إلى ذلك.
وملاحظة أخيرة، مع أن المقالات ستكون موجهة للمحرك Unity، فإن الأمور البرمجية يمكن تطبيقها على أي محرك ألعاب عند فهم منطق عملها.
بالنسبة لهذا المقال، هكذا ستكون النتيجة:
التخطيط وإنشاء المشروع
سأستخدم النسخة 2019.1.4f من محرك الألعاب Unity، يمكن الحصول عليها من هذه الصفحة.
ولكن مع ذلك يمكن تطبيق المشروع في جميع نسخ 2019، وحتى 2018 و 2017، ولكن بعض الإعدادات قد تكون مختلفة.
ملاحظة: أنا أستخدم البرنامج Unity Hub لإنشاء المشروع، ولهذا يمكنني اختيار نسخة Unity المطلوبة كما يظهر في الصورة، ولكن يمكن إنشاء المشروع باستخدام النسخة من المحرك نفسها.
عند إنشاء المشروع، اخترت النموذج Template الخاص بالألعاب ثنائية الأبعاد:
ثم قمت بترتيب مجلدات المشروع بهذا الشكل (في النافذة Project):
وبالمناسبة، هذا هو ترتيب الواجهات الذي أستخدمه لدي:
تصميم مشهد مبدئي
سنقوم بترتيب مشهد بسيط لنجرب فيه نظام الحركة، وسيكون في الـscene الافتراضي الذي يأتي عند إنشاء المشروع.
قمت بوضع هذا المشهد داخل مجلد Scenes وسميته Basic Mechanics بعد حفظه:
في داخل المشهد، سنقوم أولاً بإضافة أرضية يتحرك اللاعب عليها، لذا نذهب إلى Create -> 2D Object -> Sprite:
قمت بتسمية الكائن الجديد Ground:
هكذا أنشأنا كائن يحتوي على العنصر Sprite Renderer مسؤول عن عرض صورة، ولكننا نحتاج إلى صورة أولاً لنقوم بتعيينها إليه، ومبدئيا، سنستخدم صورة مربعة بيضاء، كهذه: (يمكن تحميلها من هنا)
قمت بإضافة الصورة إلى المجلد Sources -> Sprites
وهاهي إعدادات الاستيراد للصورة:
بعد تعيين الصورة، قمت بتغيير اللون إلى الأخضر لمحاكاة الأرضية (أعني، العشب الاصطناعي):
ثم قمت بتغيير موقع وحجم الكائن الخاص بالأرضية ليصبح هكذا:
ثم قمت بإضافة العنصر Box Collider 2D لتستطيع الشخصية بعد ذلك الوقوف والحركة فوق الأرضية (سأشرحه بالتفصيل لاحقًا):
بعد إضافته، سيتم تغيير أبعاده تلقائيا ليغطي الأرضية خاصتنا بالكامل.
قمت بتطبيق الخطوات ذاتها لإضافة جدارين ومنصتين، ليصبح المشهد بالنهاية كالآتي:
بالنسبة للون، اخترت اللون البني CEA633.
ولإضافة خلفية مبدئية، أضفت لونًا في خيار الخلفية للكاميرا كالآتي:
ملاحظة: يجب التأكد من أن قيمة الموقع على المحور z تساوي صفرًا لجميع الكائنات التي أضفناها (ما عدا الكاميرا):
تجهيز شخصية مبدئية
بما أن تركيزنا الآن يصب على برمجة الحركات الأساسية، فإننا لن نصمم شخصية، بل سنستخدم الطريقة ذاتها في تصميم المشهد لتجهيز شخصية مبدئية.
قمت بإضافة كائن جديد فارغ، عن طريق Create –> Create Empty وسميته Player.
والآن لكي أقوم بملئه بصورة ما تعبر عن وجوده، سأستخدم صورة الكبسولة هذه: (يمكن استخدام أي شكل آخر غير الكبسولة، ولكنه الأقرب لوصف شخصية في الألعاب)
بعد إضافتها للمشروع، قمت بتغيير الإعدادات للآتي:
بعد ذلك أضفتها لللاعب كابن Child، عن طريق الضغط عليه باليمين ثم اختيار 3D Object -> Sprite وعينت الصورة محل ال Sprite.
ثم قمت بإزاحة العنصر الابن وحدة للأعلى:
التعرف على الفيزياء في المحرك
الآن لنشرح ماهي المصادمات Colliders وما أهميتها. ثم نتعرف على كيفية عمل الفيزياء في المحرك. (يمكن اختصار هذا الجزء إذا كانت لديك معرفة سابقة)
باختصار، لحدوث التصادمات بين الأجسام في اللعبة، فنحن بحاجة إلى تحديد أبعاد كل جسم.
تحديد الأبعاد يعني أن نحدد المنطقة “الصلبة” للجسم، التي لا يمكن لجسم آخر التواجد فيها في نفس الوقت.
بالطبع هنا أفترض أننا نتعامل مع أجسام صلبة تم تشغيل الفيزياء لها.
وهذه الأبعاد قد تأتي بعدة أشكال مختلفة، منها المربعات والمستطيلات والدوائر والأسطوانات الكبسولات ويماثلها الأشكال ثلاثية الأبعاد مثل المكعبات والكرات Spheres.
هذه الأشكال جميعها تسمى مصادمات بدائية Primitive Colliders
وهناك أشكال معقدة أكثر كمجسم كرسي أو سيارة أو بيت أو حتى مجسم إنسان، ولهذه الأشكال يتم إما استعمال المصادمات الشبكية Mesh Colliders أو تبسيطها إلى مصادمات بدائية.
وشخصيتنا في اللعبة ستكون بسيطة، لذلك يمكن تبسيطها إلى مصادم كبسولة ثنائية الأبعاد Capsule Collider 2D.
الكبسولة ، في برمجة الرسوميات و الهندسة على الأقل، تتكون من أسطوانة و نصفي دائرة متباعدتان عن بعضهما على كل طرف.
هنا أضفت مصادم كبسولة ثنائي الأبعاد 2D Capsule Collider إلى الأب Player وغيرت أبعاده كالآتي:
ولكن حتى بعد كل هذا، ما زال علينا تشغيل الفيزياء لهذا المجسم، أي جعله يستجيب للقوى كالجاذبية ويتصادم ويتفاعل مع الأجسام الأخرى.
كيف نفعلها؟ ببساطة، نضيف عنصر Rigidbody2D إلى الكائن Player :
هناك الكثير من الخيارات هنا وليس علينا أن نشغل أنفسنا بهم، كل ما يهم أن يكون نوع الجسم Dynamic أي متحرك.
الآن أصبح بإمكاننا التحكم بالشخصية وتحريكها عن طريق تغيير خواص الـRigidBody2D، مثلاً، يمكننا تغيير سرعته أو إعطائه دفعة لكي تتحرك الشخصية.
برمجة الأنظمة الأساسية للشخصية
المحرك Unity يعتمد أسلوب المكونات للأنظمة البرمجية، أي أنه لكي نشغل ملف برمجي ما لكائن معين، يجب أن يتم تعيينه لهذا الكائن كمكون Component.
سنبدأ أولاً بملف يقوم بإدارة شخصية اللاعب بشكل كامل، من تحديد الحالة الحالية للشخصية، إلى تحريكها وتشغيل ملفات الانيميشن Animation لها.
في الكائن Player، أضفت ملف برمجي سميته PlayerManager:
سيقوم هذا الملف بتحديث الحالة للشخصية، وأمر الملفات الأخرى، التي سنضيفها، بتنفيذ مهامها.
التعرف على إعدادات الإدخال (التحكم)
قبل أن نبدأ عملنا في الملف PlayerManager، سنقوم ببرمجة الاستجابة لأوامر التحكم من اللاعب، أي عندما يضغط زر الحركة، نحصل على قيمة أو رقم يخبرنا بالاتجاه.
سنفعل ذلك في ملف برمجي مستقل نسميه PlayerInput، نضيفه إلى كائن اللاعب Player.
في المحرك Unity، يمكن التعامل مع أوامر التحكم باستعمال ما يسمى بال Axes أو المحاور. وهي عبارة عن عناصر مسجلة مسبقًا تأخذ بياناتها من عناصر إدخال محددة.
توجد هذه المحاور في الإعدادات: Edit -> Project Settings -> Input. داخل المصفوفة Axes.
في المشروع الافتراضي يكون أول عنصر مسمى Horizontal أي التحكم الأفقي.
بداخل هذا العناصر هناك خاصية تسمى Positive Button، أي الزر الذي ينتج عنه قيمة موجبة.
وفي حالتنا هذا الزر هو right أي السهم الأيمن، لذلك عند ضغط هذا الزر، سنحصل على قيمة موجبة. أما السهم المتجه لليسار سينتج عنه قيمة سالبة.
وأيضًا الزرين d و a يحلان محل السهم الأيمن والأيسر، كما نرى في Alt Positive / Negative Button.
الاستجابة لأوامر اللاعب
الآن كيف نحصل على هذه القيم؟ هناك دالة موجودة في النوع Input وهي GetAxis، نعطيها الاسم المطلوب لتعطينا قيمة الإدخال بناءً على تحكم اللاعب.
سنقوم بحفظ هذه القيمة في خاصية لنستطيع الحصول عليها من أي مكان خارج هذا الملف، وسنقوم بتعيين الاسم المطلوب بمتغير خاص نعينه من الواجهة inspector.
هكذا يصبح الملف البرمجي PlayerInput:
sing System.Collections; using System.Collections.Generic; using UnityEngine; public class PlayerInput : MonoBehaviour { [SerializeField] string movingInputName = "Horizontal"; public float horizontalMovingInputValue { get; private set; } /// <summary> /// الدالة تحصل على القيم لعناصر التحكم وتعينها لمتغيراتها المخصصة. /// يجب أن تستدعى هذه الدالة بشكل متكرر كل إطار. /// </summary> public void CheckContinuousInput(){ //الدالة GetAxis تأخذ اسم عنصر التحكم وتعطينا القيمة //تكون القيمة بين ال0 وال1 horizontalMovingInputValue = Input.GetAxis(movingInputName); } }
لاحظوا أنني قمت بكتابة دالة جديدة مسؤولة عن الحصول عن القيمة لعناصر التحكم، ولكنني لم أستدعي هذه الدالة بعد.
يجب علينا استدعائها بشكل متكرر، وبما أننا سنعتمد على الفيزياء لتحريك الشخصية، يجب أن نستدعيها داخل الـFixedUpdate.
أين؟ يمكننا وضعها في نفس الملف، ولكنني قمت بإنشاء الملف PlayerManager لجعله هو المسؤول عن أمر الملفات البرمجية الأخرى بتنفيذ مهامها.
لذلك في الملف البرمجي PlayerManager أضفت مرجع للحصول عن ملف التحكم واستدعيت الدالة كل إطار:
using UnityEngine; public class PlayerManager : MonoBehaviour { public Rigidbody2D rig2D { get; private set; } //خاصية تمثل عنصر الملف البرمجي الخاص بالتحكم. public PlayerInput inputHandler { get; private set; } void Awake() { rig2D = GetComponent<Rigidbody2D>(); //نحصل على الملف عن طريق الدالة GetComponent //هذه الدالة تعطينا العنصر بناءً على المكون المطلوب inputHandler = GetComponent<PlayerInput>(); } //هذه الدالة يتم استدعائها بشكل متكرر كلما يتم إعادة تحديث الفيزياء void FixedUpdate() { inputHandler.CheckContinuousInput(); } }
سنفهم أهمية استدعاء الدوال والأوامر من خلال هذا الملف عند تطبيق الخواص الأخرى.
برمجة الحركة الأفقية
بعد أن حصلنا على قيمة زر التحكم، يجب علينا تحريك الشخصية، لذلك سنضيف الآن ملف برمجي آخر مسؤول عن الحركة، نسميه PlayerMovement.
ما يجب أن يقوم به هذا الملف الآن هو الآتي:
- يأخذ مرجع لملف التحكم PlayerInput ليحصل على قيم الإدخال منه.
- يقوم بتغيير اتجاه الشخصية بناءً على المدخلات (مثلا يتجه لليسار عند ضغط السهم الأيسر)
- يحرك الشخصية في هذا الاتجاه بسرعة محددة، عن طريق تغيير سرعة الـمكون RigidBody2D velocity
هكذا يصبح الملف PlayerMovement لدينا:
using UnityEngine; public class PlayerMovement : MonoBehaviour { //حقل السرعة، من هنا نحدد السرعة القصوى التي ستتحرك بها الشخصية [SerializeField] float maxSpeed; //خاصية نغيرها بناء على اتجاه اللاعب. لدينا اتجاهان فقط - اليمين واليسار public bool isFacingRight { get; private set; } //مراجع لملف الإدخال والملف المدير PlayerInput inputHandler; PlayerManager playerManager; //متغير خاص يتغير بناءً على قيمة الإدخال float x_axis; //دالة تستخدم عند البدء لتمرير المراجع public void Initialize(PlayerManager _playerManager) { playerManager = _playerManager; inputHandler = playerManager.inputHandler; } //نعين متغير الاتجاه بأنه يتجه لليمين - فهو الاتجاه الافتراضي void Start() { isFacingRight = true; } public void UpdateMovement() { // نتأكد أن قيمة الإدخال تكون بين ال0 وال1 تحديدًا x_axis = Mathf.Clamp01(inputHandler.horizontalMovingInputValue); //عندما يكون الاتجاه لليسار - أي القيمة سالبة if (inputHandler.horizontalMovingInputValue < 0) x_axis *= -1; // نتأكد ما إذا تم تغيير الاتجاه - انظر الدالة في الأسفل if (IsDirectionFlipped(x_axis)) { //نغير الاتجاه عن طريق عكس قيمة الحجم على المحور الأفقي x //إذا كنا نتجه لليسار، نضرب قيمةالمحور x بـ 1- أي سينعكس على نفسه ليظهر وكأنه يتجه لليسار transform.localScale = Vector3.Scale(transform.localScale, new Vector3(-1, 1, 1)); } } public void MoveHorizontally() { /* الحركة لليسار واليمين */ //نحدد السرعة عن طريق ضرب قيمة الإدخال بالسرعة القصوى. float x_vel = x_axis * maxSpeed; //نعين هذه السرعة للحركة على المستوى الأفقي //ونبقي الحركة على المستوى الرأسي كما هي playerManager.rig2D.velocity = new Vector2(x_vel, playerManager.rig2D.velocity.y); } //دالة تخبرنا ما إذا تم تغيير الاتجاه private bool IsDirectionFlipped(float x_axis) { //إذا كنا متجهين لليسار في الأصل والإدخال يتجه لليمين: if(x_axis > 0 && !isFacingRight) { return true; } //أو إذا كنتا متجهين لليمين في الأصل والإدخال يتجه لليسار: else if(x_axis < 0 && isFacingRight) { return true; } return false; } }
ما هما الـ Vector3 و Vector2؟
استخدمناهما في الملف البرمجي في موقعين، الأول عند تغيير اتجاه الشخصية، حيث نغير الحجم ونعكسه، والثاني عند تحريك الشخصية، حيث نعين السرعة للشخصية.
الـVector3 هو متجه ثلاثي الأبعاد، في مستوى إحداثي إقليدي ثلاثي الأبعاد أي لديه ثلاث مركبات، المركب الأفقي x وهو الأول، والمركب الرأسي y وهو الثاني، ومركب البعد الأخير z.
الـVector2 مماثل تمامًا ولكنه متجه ثنائي الأبعاد، أي x و y فقط.
وبما أن لعبتنا ثنائية الأبعاد، فإننا نستطيع الاعتماد على الـVector2.
ما هو المتجه؟ ببساطة، خط مستقيم، قد يبدأ من نقطة محددة، له طول محدد واتجاه في المستوى الإحداثي.
لتحريك الشخصية فيزيائيًا، فإننا نحتاج لتحديد سرعة المكون Rigidbody2D للجسم، وبعد تحديدها، يقوم محرك الفيزياء بتحريك الجسم بشكل دقيق بناءً على هذه السرعة.
بما أننا نريد التحرك في المحور الأفقي فقط مبدئيًا، قمت بتغيير السرعة في المركب الأول (x) فقط، وتركتها كما هي للمركب y.
لماذا استخدمت Vector3، طالما أن الحركة ثنائية الأبعاد؟
سؤال جيد. عند عكس اتجاه الشخصية عبر تغيير الحجم، قمت باستخدام متجه ثلاثي الأبعاد، والسبب أن الحجم الأصلي للجسم يحدد بمتجه ثلاثي الأبعاد، أي له ثلاث مركبات.
وإذا قمت بتعيين الحجم كمتجه ثنائي الأبعاد، سيتم تعيين المركب الثالث z بالقيمة 0، ولكن من الأفضل أن يكون الحجم uniform أي مماثل في كل الأبعاد، لذلك أبقيته بنفس قيمة المركبي x و y.
في داخل الدالة MoveHorizontally قمت بتغيير سرعة الجسم على حسب قيمة الإدخال، وهذه تبدأ من ال0 إلى 1، لذلك السرعة تتغير نسبيًا حتى تصل إلى أقصى سرعة (ولكنها تفعل ذلك بسرعة لذا التأثير لا يلاحظ)
لاحظوا أنني لم أستدعي هذه الدالة في أي مكان، وكما اتفقنا، سنقوم باستدعائها من الملف المدير.
نذهب إلى PlayerManager وفي الدالة FixedUpdate نضيف الآتي:
... public PlayerMovement playerMover { get; private set; } ... void Awake() { rig2D = GetComponent<Rigidbody2D>(); inputHandler = GetComponent<PlayerInput>(); playerMover = GetComponent<PlayerMovement>(); playerMover.Initialize(this); } ... void FixedUpdate() { inputHandler.CheckContinuousInput(); playerMover.UpdateMovement(); } ...
يمكن الآن تجربة اللعب !
لقد نسينا أمرًا مهمًا، أتذكرون إعدادات الـRigidBody2D؟ لنعد إليها قليلاً، ونلاحظ أنه هناك ثلاث مربعات في الأسفل، أحدها هو FreezeRotation.
لنجرب تثبيت الدوران عبر تشغيل هذا الخيار:
جميل 🙂
إضافة تسارع
لاحظنا أن التغيير في السرعة هنا يحصل بسرعة، أي أن الشخصية تبدأ من الصفر وفورًا تصل إلى أقصى سرعة لها.
ولكن في معظم الألعاب فإنه يتم تطبيق تسارع للاعب، عند البدء وعند التوقف، لجعل الحركة واقعية أكثر.
لإضافة هذا التأثير، بدلاً من تحديد السرعة اعتمادًا على قيمة زر الإدخال مباشرة (Input)، سأقوم بتغييرها تدريجيًا، من السرعة الحالية إلى السرعة المطلوبة.
وسوف أقوم بهذا الأمر في كل لحظة – أي كل إطار – مما يعني أن السرعة الحالية ستكون دائما تتجه وتتغير إلى السرعة المطلوبة.
كيف سنفعلها؟ إذا قمنا بتحديد متغير لمدة أو وقت، نصل خلالها إلى السرعة المطلوبة، بحيث يكون هذا المتغير صغير نوعًا ما (0.5~ ثانية)، يمكننا استنتاج معادلة بسيطة من معادلة السرعة:
(1) Vf = Vi + a * t.
Vf = Final Velocity السرعة المحددة (المطلوبة).
Vi = Initial Velocity السرعة الحالية
t = Time to Reach Speed مدة الوصول إلى السرعة المطلوبة
a = Acceleration العجلة (التسارع)
نحن لدينا السرعة الحالية والسرعة المطلوبة والزمن المطلوب للوصول لهذه السرعة، يمكن حل المعادلة لإيجاد العجلة
(2) a = (Vf – Vi) / t
كيف نطبق العجلة؟ يمكننا إما استخدام المعادلة الأولى (1)، ونبدل المتغير Time ب DeltaTime، ثم نغير السرعة يدويًأ أو نطبق التسارع باستخدام دوال المكون Rigidbody2d.
من هذه الدوال دالة AddForce، نعطيها القوة (أو العجلة) المطلوبة، ثم تقوم بتطيقها على الجسم مباشرة.
سنقوم بهذه الأمور في الملف البرمجي PlayerMovement:
... [Tooltip("الوقت المستغرق للوصول إلى السرعة المطلوبة. يستخدم في حساب العجلة")] [SerializeField] float timeToReachSpeed = 0.1f; ... public void MoveHorizontally() { //نحدد السرعة المطلوبة عن طريق ضرب قيمة الإدخال بالسرعة القصوى. float x_vel = x_axis * maxSpeed; //السرعة الحالية float oldVel = playerManager.rig2D.velocity.x; //العجلة (أو القوة) المطلوبة للوصول إلى السرعة اعتمادًا على الزمن. float currForce = (x_vel - oldVel) / timeToReachSpeed; //تطبيق هذه القوة playerManager.rig2D.AddForce(currForce * Vector2.right); }
لاحظوا أنني أستخدم مصطلحي القوة والعجلة بالتبادل، وذلك لأن القوة هي العجلة مضروبة في الكتلة، وبما أن الكتلة لدينا 1kg ، فالقيمتان تكونان متساويتين.
مشكلة الأرقام الصغيرة
هناك مشكلة صغيرة وهي أن الشخصية قد لا تصل إلى السرعة المطلوبة بشكل دقيق بالوقت المطلوب، وذلك لأنه عندما يصبح الفرق في السرعتين، x_vel و oldVel، قليل، تصبح الأرقام صغيرة نسبيًا، فعند تطبيق القوى، قد لا تكون القوى كافية، لذا ستبقى الشخصية تقترب من هذه السرعة دون أن تصل إليها.
الحل ببساطة أن نتأكد من الفرق، إذا كان صغير نسبيًا، نقوم بتعيين السرعة المطلوبة مباشرة بدلاً من تطبيق القوى.
نضيف التعديل الآتي في الدالة MoveHorizontally:
public void MoveHorizontally() { ... float x_vel = x_axis * maxSpeed; float oldVel = playerManager.rig2D.velocity.x; if (Mathf.Abs(x_vel - oldVel) <= 0.1f){ playerManager.rig2D.velocity = new Vector2(x_vel, playerManager.rig2D.velocity.y); } else{ float currForce = (x_vel - oldVel) / timeToReachSpeed; playerManager.rig2D.AddForce(currForce * Vector2.right); } }
توقف سريع
نريد أن يكون التوقف أسرع من البدء في المشي أو الحركة، فهكذا تكون الحركة في الواقع.
لتطبيق ذلك، نفحص ماذا إذا كان اللاعب قد تباطئ، أي أن قيمة الإدخال بدأت تقل، ثم نقوم بزيادة القوة المطبقة لجعله يصل للسرعة الأبطأ، حتى يتوقف، بشكل أسرع.
في الملف PlayerMovement نضيف الآتي للدالة MoveHorizontally:
public void MoveHorizontally() { ... if (Mathf.Abs(x_vel - oldVel) <= 0.1f){ ... } else { float time = timeToReachSpeed; //إذا كان اللاعب يتباطئ: (قيمة الإدخال تنخفض) if (Mathf.Abs(x_vel) < Mathf.Abs(oldVel)){ //عبر تقليل المدة المطلوبة للوصول للسرعة، تزيد القوة. time *= 0.5f; } float currForce = (x_vel - oldVel) / time; playerManager.rig2D.AddForce(currForce * Vector2.right); } }
تباطئ عند تغيير الاتجاه.
يمكن أن نلاحظ في الكثير من ألعاب المنصات تباطئًا واحتكاكًا بالأرضية عند تغيير الاتجاه من اليمين إلى اليسار أو العكس.
سنحاكي هذا الأسلوب بتقليل القوة (العجلة) المطبقة عند تغيير إشارة الإدخال (التحكم).
ولفعل ذلك يمكننا زيادة الوقت المطلوب للوصول إلى السرعة، مثلاً مضاعفته.
في الملف PlayerMovement:
public void MoveHorizontally() { ... if (Mathf.Abs(x_vel - oldVel) <= 0.1f){ ... } else { float time = timeToReachSpeed; if (Mathf.Abs(x_vel) < Mathf.Abs(oldVel)) { time = 0.5f * timeToReachSpeed; } //لفحص ما إذا تم تغيير الاتجاه، نقوم بمقارنة إشارة السرعة الحالية والسرعة المطلوبة. // نستخدم الدالة الجديدة DifferentSigns لهذه المقارنة، حيث نعطيها القيمتين، وتخبرنا ما إذا كانا متعاكسين في الإشارة. //(الدالة موجودة في الأسفل) if (DifferentSigns(x_vel, oldVel)) { //بزيادة المدة، تقل العجلة ثم يظهر التباطئ أو الاحتكاك. time *= 2f; } float currForce = (x_vel - oldVel) / timeToReachSpeed; playerManager.rig2D.AddForce(currForce * Vector2.right); } } //دالة نعطيها قيمتين، وتخبرنا ما إذا كانا متعاكسين في الإشارة private bool DifferentSigns(float first, float second) { //إذا كانت القيمة الأولى أقل من الصفر والثانية أكبر أو تساوي الصفر.. //أو العكس.. //تكون الإشارتان مختلفتين if (first < 0 != second < 0) return true; }
إضافة ركض – سرعة أكبر !
بما أنه من الممل المشي بسرعة بطيئة طوال الوقت، يمكننا إضافة إمكانية تغيير سرعة الشخصية عند الضغط (والإبقاء) على زر معين.
في قائمة المحاور في الأعدادات Input -> Axes نضيف عنصر جديد نسميه Run.
هكذا تكون إعداداته، سأستخدم الزر Left Shift له:
والآن في الملف البرمجي PlayerInput، نضيف خاصية تخبرنا ما إذا كان اللاعب مستمرًا في الضغط على زر زيادة السرعة.
... [SerializeField] string runInputName; ... public bool isHoldingRunKey {get; private set;} ... public void CheckContinuousInput(){ ... //الدالة GetButtonDown تعطينا قيمة صحيحة True ما دام الزر مضغوطًا: isHoldingRunKey = Input.GetButtonDown(runInputName); }
ثم في الملف البرمجي PlayerMovement، لتغيير السرعة عند الركض، يمكننا إما مضاعفة خاصية السرعة الأصلية maxSpeed، أو إضافة متغير جديد.
بالنسبة لي، لكي أجعل الأمور واضحة، سأستخدم متغير جديد يعبر عن سرعة الركض runSpeed، وأعيد تسمية المتغير maxSpeed إلى walkSpeed ليعبر عن السرعة المطلوبة أثناء المشي.
بالطبع يمكن بدلاً من ذلك استخدام متغير واحد maxSpeed لسرعة الركض واستخدام نصف قيمته للحالة العادية – المشي. هذا الأمر يرجع إليكم.
والآن فقط نقوم بتغيير بسيط داخل الدالة MoveHorizontally:
[SerializeField] float runSpeed; [SerializeField] float walkSpeed; ... public void MoveHorizontally() { ... //نعين السرعة العادية المطلوبة بأنها سرعة المشي float x_vel = x_axis * walkSpeed; //في حالة الضغط - أي الركض. if(inputHandler.isHoldingHighSpeed) x_vel = x_axis * runSpeed; //سرعة الركض ... } ...
هكذا يصبح اتمام المراحل أمرًا أسهل:
هناك طرق أخرى – وهذه فقط البداية
مع أننا حققنا المطلوب، وقمنا ببرمجة الحركات الأساسية، فالطرق والمعادلات والآليات التي استخدمناها هي ليست الوحيدة، وربما ليست الأفضل.
يمكن برمجة هذه الحركات بأسلوب مختلف تمامًا، مثلاً دون الاعتماد على الفيزياء في المحرك (Rigidbody)، أو دون استخدام معادلات الحركة للتسارع.
لكن المهم بالنسبة لنا أنها حققت المطلوب دون مشاكل، وربما في مقال قادم في السلسلة، سأقوم بإعادة برمجة الحركات الأساسية دون استخدام Rigidbodies، وهذا ما يفعله معظم مبرمجي ألعاب الـPlatformers..
لكنني استخدمتهم هذه المرة لأجعل الشرح أسهل قليلاً.
الآن نكون قد أتممنا الحركات الأساسية – المشي والركض – لشخصية لعبة المنصات Platformer.
وسنقوم في المقال القادم إن شاءالله ببرمجة القفز والارتداد عن الجدران، ثم بعد ذلك تحريك الكاميرا لتتبع الشخصية، ثم يليها استخدام شخصية جميلة مزودة بملفات متحركة Animations، مما يجعل اللعبة حيوية أكثر.
أتطلع لأسئلتكم وآرائكم في التعليقات 🙂