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

ماذا سنتعلم؟
- التعامل مع الطبقات Layers والـLayerMask
- استخدام الـRaycasts
- معامدات الأسطح Surface Normals
- قائمة الملامسات ContactPoints للـRigidbody2D
(وجود خبرة سابقة في هذه الأمور جيد، ولكن ليس ضروري أبدًا)
التخطيط لعملية القفز
لنقم بتحليل الخطوات أولاً، ونركز على القفز فقط مبدئيًا:
- قبل القفز، يجب أن نتأكد أن اللاعب ملامس أو قريب للأرضية
- إذا كان كذلك، نسمح له بالقفز عن طريق الضغط على زر محدد
- إضافة قوة دافعة للقفز عاليًا
- لا يمكنه القفز مرة أخرى وهو محلق طالما أنه لن يكون ملامسًا للأرضية أثناء ذلك.
هل هذا كل شيء؟ بالنسبة للأمور الأساسية، نعم، ولكن في معظم ألعاب الـPlatformer،هناك خاصية غالبًا ما تطبق:
- جعل تسارع أو قوة الجاذبية أثناء النزول مضاعفة عن قوتها أثناء القفز، مما يجعل الشخصية تهبط بسرعة.
سنبدأ بالمهام الأساسية ثم نطبق هذه الخاصية، هكذا سنستطيع ملاحظة أهميتها.
اكتشاف ملامسة الأرض – التعرف على Raycast
تذكروا سابقًا أننا أضفنا ما يسمى بالمصادم، Box Collider 2D، إلى الأرضية، ليستطيع اللاعب الوقوف والتحرك عليها ولا ينزل إلى الأسفل.
للتأكد أن اللاعب ملامس للأرضية، يجب أن يقف فوق أحد هذه المصادمات، بغض النظر ما إذا كانت الأرضية Box أو Circle أو Capsule.
كيف نفعل ذلك؟ من الصعب المقارنة بين موقع اللاعب على المحور y وموقع الأرضية، لأن لعبتنا لعبة منصات وستكون المنصات بارتفاعات ومواقع مختلفة.
لحسن الحظ، يمكننا الاستعانة بمحرك الفيزياء المدمج، وبخاصة، استخدام ما يسمى بالـRayCast:
الـRayCast هو إرسال شعاع – خط مستقيم – من نقطة بداية معينة باتجاه محدد لمسافة محددة، واكتشاف أول مصادم جسم (collider) يقطعه الشعاع في عالم اللعبة.
حدود الأجسام يتم تعيينها بالـColliders كما ذكرنا سابقًا، لذا إذا تقاطع الشعاع مع أحد هذه الـColliders، يمكننا الحصول على معلومات عنه.
كيف نستفيد منه لاكتشاف الأرضية؟ ببساطة، نطلق شعاع من موقع الشخصية إلى الأسفل، لمسافة صغيرة جدا (0.1 متر)، وإذا تقاطع الشعاع مع مصادم الأرضية، نعتبر أن الشخصية ملامسة لها.
لنكتشف الآن الدالة Physics2D.Raycast من موقع Unity:
public static RaycastHit2D Raycast ( Vector2 origin, Vector2 direction, float distance = Mathf.Infinity, int layerMask = DefaultRaycastLayers, float minDepth = -Mathf.Infinity, float maxDepth = Mathf.Infinity);
لا تفزعوا ! مع أن للدالة أكثر من معامل، لا يهمنا سوى أول أربعة منهم:
- origin هو موقع بداية الشعاع، وفي حالتنا، موقع الشخصية، transform.position. ولكنني سأقوم بإضافة transform اسمه groundCheckPos مسؤول عن موقع بداية الشعاع.
- direction وهو اتجاه الشعاع، سيتجه للأسفل أي Vector2.Down
- المسافة distance، إذا كانت المسافة بين الشخصية والأرضية 0.1~ مترًا، سنعتبر أنه ملامس لها. لذلك ندخل القيمة 0.1
- layerMask، معامل يحدد أنواع الطبقات التي سيتم الفحص عنها، سأشرح عنه بالتفصيل الآن.
التعرف على الطبقات Layers
مفهوم الطبقات يتم استخدامه في العادة لتحسين الأداء والاقتصاد في استهلاك الموارد.
يمكننا أن نعين لكل كائن في اللعبة طبقة ينتمي إليها، وهذه الطبقات ليست مسؤولة عن ترتيب العرض مثل الطبقات ثنائية الأبعاد، وإنما فقط تستخدم للتصنيف.
مثلاً، لأي كائن يمثل أرضية يمكن للاعب أن يمشي عليها، سننشئ طبقة جديدة نسميها Ground ونجعل هذا الكائن ينتمي إليها.
بعد ذلك، عند التحقق من ملامسة الأرضية، نحدد في المتغير layerMask طبقة الأرضية فقط، هكذا لن يتم التحقق إلا من الأجسام التي تنتمي لهذه الطبقة.
وهكذا نكون قد خففنا عدد التحقيقات وسرعنا العملية بشكل كبير، ففي معظم المشاريع، يكون هناك ما لا يقل عن 10 طبقات مختلفة، ولا حاجة للتأكد منهم جميعا.
وفي حال ا أضفتُ كائنات أخرى في اللعبة، وأريد أن أسمح للشخصية بالوقوف عليها، ولكن لا أريد جعلها تنتمي للطبقة Ground، يمكنني إنشاء طبقة أخرى خاصة بهذه الكائنات وإضافتها للمتغير layerMask.
وفي الحقيقة، ليس من الضروري إنشاء طبقة جديدة Ground، يمكن استعمال الطبقة الافتراضية Default واعتبار عناصرها أنها من الأرضية، لكن بالنسبة لي سأقوم بإنشاء طبقة جديدة.
وأيضًا، نحن لا نريد اعتبار شخصية اللاعب أنها أرضية، فعند إطلاق الشعاع من نقطة فوق موقع الشخصية (سنفعل ذلك عند التحقق من وجود جدار)، قد يصطدم بالشخصية ويعتبر أننا ملامسين للأرضية.
لذلك سنضيف طبقة خاصة للشخصية ثم نستبعدها من طبقات الأرضية في الـlayerMask.
لكي نضيف طبقة للشخصية وطبقة للأرضية، نذهب إلى Edit -> Project Settings -> Tags and Layers، ثم في مصفوفة الطبقات، نضيف الطبقتين، هكذا:

والآن نذهب إلى كائنات الأرضية والشخصية في مشروعنا، وفي الأعلى يمينًأ، نضغط على الlayers ونختار الطبقة المناسبة:

برمجة اكتشاف ملامسة الأرضية
في داخل الملف البرمجي PlayerManager، وفي داخل الدالة FixedUpdate، سنقوم بالتأكد من ملامسة الأرضية عن طريق الـRaycast.
سنستخدم FixedUpdate لأننا نريد التأكد كل إطار، ولإبقاء التحقق بتزامن مع الفيزياء.
ولتنظيم الأمور أكثر، سأضع الكود في دالة جديدة أسميها CheckGround، تخبرنا ما إذا كانت الشخصية ملامسة للأرضية:
//المسافة التي سيمتد الشعاع فيها لكشف الأرضية [SerializeField] float groundCheckDist = 0.1f; //طبقات الأجسام التي تعتبر من الأرضية [SerializeField] LayerMask groundLayers; [SerializeField] Transform groundCheckPos; public bool grounded { get; private set; } void FixedUpdate(){ grounded = false; inputHandler.CheckContinuousInput(); CheckGrounded(); playerMover.UpdateMovement(); } private void CheckGrounded() { RaycastHit2D raycastHit = Physics2D.Raycast(groundCheckPos.position , Vector2.down, groundCheckDist, groundLayers); grounded = raycastHit.collider != null; }
والآن في المحرر inspector، نعدل المتغير groundLayers هكذا:

ثم نضيف كائن Empty Transform مختص بتحديد موقع إطلاق الشعاع، هكذا:

التحقق
للتحقق من أنه يتم اكتشاف الأرضية بشكل صحيح، يمكن إضافة أمر طباعة يطبع القيمة الحالية للمتغير grounded.
وسيظهر أمر الطباعة في الـConsole في الأسفل.
بعدها يمكن تجريب اللعبة ورفع الشخصية إلى الأعلى عبر التعديل المباشر على الموقع y.
إضافة عنصر إدخال للقفز والاستجابة له
مرة أخرى، في المحاور Axes في إعدادات الإدخال Input، هناك عنصر افتراضي معرف مسبقًا مسمى Jump، سنستعمله للاستجابة في القفز.
أين نستجيب؟ قمنا سابقًا باستدعاء الدالة CheckContinuousInput من خلال الملف المدير كل إطار فيزيائي، أي داخل FixedUpdate.
أما لنستجيب للضغط على القفز، سنقوم بالتاكد كل إطار، أي من دالة الـUpdate،
لماذا؟
لاحظوا أولاً أنني قمت بتسمية الدالة الأولى Continuous، أي مستمر.
وذلك لأن القيم التي يتم قرائتها من عناصر الإدخال في هذه الدالة، مثل قيمة التحكم بالحركة الأفقية Horizontal، هي قيم لا تختلف بشكل كبير في كل إطار، وتتغير وتنتقل بانتظام بين قيمة وأخرى.
أي مثلا إذا كانت القيمة 0، فعند الضغط على عنصر الإدخال الموجب، تبدأ بالتغير شيئَا فشيئًا حتى تصل إل القيمة 1.
أما مثلا عنصر التحكم الخاص بالقفز، فالقيمة الخاصة به تتغير فقط لمدة إطار واحد – عند الضغط. وهذه القيمة لا تكون قيمة عددية، وإنما إما صحيحة true أو خاطئة false، أي boolean.
وتسمى إدخال مرة واحدة One Time Input.
لماذا في الـUpdate إذًا؟ سأقوم بالتفصيل في مقال آخر، المهم هو أن الـUpdate تستدعى كل إطار، أما الـFixedUpdate لا تستدعى إلا في إطارات الفيزياء.
لذلك إذا اعتمدنا على الـFixedUpdate سنفقد الكثير من أوامر اللاعب، أي أن اللاعب قد يضغط ولكن الملف البرمجي لن يلاحظ ذلك.
الملف البرمجي PlayerInput:
[SerializeField] string jumpInputName = "Jump"; public bool pressedJump { get; private set; } /// <summary> /// تتأكد من قيم التحكم والإدخال التي ترسل مرة واحدة فقط وتعينهم لخصائصهم. يجب أن تستدعى كل إطار بدالة الـUpdate /// </summary> public void CheckClickInput() { pressedJump = Input.GetButtonDown(jumpInputName); }
نستدعيه الآن من الملف المدير PlayerManager:
... void Update() { inputHandler.CheckClickInput(); } ...
فقدان أوامر اللاعب
بالرغم من استخدامنا للدالة Update، فإنه ما زال من الممكن فقد أوامر اللاعب (بإمكانكم التأكد من ذلك باستخدام الطباعة)
تذكروا أننا سنستخدم الدالة FixedUpdate لجعل الشخصية تقفز، لأننا نحركها بالفيزياء.
في بعض الحالات، قد يمر إطار Update ويكون اللاعب قد ضغط زر القفز، ولا يمر إطار فيزياء بعده، بل يمر إطار Update، وفي هذا الإطار، لا يكون اللاعب ضاغط للزر، لذا نحصل على قيمة false للمتغير pressedJump.
ثم عند بدء إطار الفيزياء، FixedUpdate، ونفحص قيمة المتغير pressedJump للقيام بالقفز، نجدها قيمة خاطئة false.
لذلك ما سنقوم به هو أنه في كل إطار Update، لا نقوم بتعيين المتغير pressedJump من قيمة الإدخال إلا إذا كانت قيمته خاطئة false بالأصل.
ثم في كل إطار فيزياء، بعد القيام بجميع عمليات الحركة والقفز، نرجع قيمة المتغير pressedJump إلى false>
بمعنى آخر، بعد الضغط على الزر لمرة واحدة، تبقى القيمة true إلى حين مرور إطار فيزياء.
في الملف PlayerInput :
... public void CheckClickInput() { if(!pressedJump) pressedJump = Input.GetButtonDown(jumpInputName); } ... //دالة تقوم بإعادة عناصر الإدخال المرة الواحدة إلى قيمهم الافتراضية //يجب استدعائها بعد استعمال جميع عناصر الإدخال والقيام بالعمليات اللازمة public void ResetFrameVariables(){ pressedJump = false; }
ثم في الملف البرمجي PlayerManager:
... void FixedUpdate() { ... //يجب أن يستدعى هذا السطر في نهاية الدالة بعد كل شيء inputHandler.ResetFrameVariables(); }
القفز باستخدام الفيزياء
في الملف البرمجي PlayerMovement..
سنضيف في الأعلى متغير يحدد قوة القفز، ثم دالة جديدة نستدعيها من UpdateMovement، تفحص ما إذا تم الضغط على زر القفز، وتتطبق القوة.
... [Header("Jumping")] //قوة اندفاع الشخصية للأعلى عند القفز [SerializeField] float jumpForce = 1f; ... public void UpdateMovement() { ... DoJump(); } public void DoJump() { if (inputHandler.pressedJump && playerManager.grounded) { playerManager.rig2D.AddForce(transform.up * jumpForce,ForceMode2D.Impulse); } }
قمت بتعيين متغير القوة بالقيمة 10، وبعد التجربة، حصلت على هذا:

جعل القفز ملائمًا للألعاب أكثر
لاحظوا في الصورة في الأعلى أن الشخصية تأخذ وقتًا طويلاً حتى تعود إلى الأرض بعد القفز، وهو ليس ما اعتدنا عليه في ألعاب الـPlatformer.
وأيضًا نحن اعتدنا على أنه عند الضغط بشكل مطول على زر القفز، فإن الشخصية تقفز لمستوى أعلى.
سنقوم بتطبيق هاتين الميزتين الآن.
مضاعفة الجاذبية
لجعله ينزل بسرعة أكبر، يمكننا إما مضاعفة الجاذبية المطبقة على الشخصية ( من خصائص الـRigidbody2D)
أو تطبيق قوة إضافية تسحبه للأسفل، وهو الأفضل.
هذه القوة تتطبق فقط ما إذا كان اللاعب يتجه للأسفل، أي في البداية عند قفزه للأعلى، لن نطبق أي قوة للسماح له بالارتفاع عاليًا.
سنضيف متغير boolean يخبرنا ما إذا كان علينا تطبيق القوة، أي إذا كان اللاعب محلقًا ومتجهًا للأسفل، ثم في نفس الدالة DoJump، نطبق القوة:
[SerializeField] float downwardGravityForce = 15f; public void DoJump() { //إذا كان اللاعب غير ملامس للأرضية ومتجه للأسفل bool applyGravityForce = !playerManager.grounded; if (applyGravityForce) { //لاحظوا أنني أستخدم سالب (عكس) الاتجاه الرأسي للشخصية. //وهو الاتجاه الأسفل، ثم أضربه بالقيمة المطلوبة من القوة. playerManager.rig2D.AddForce(-transform.up * downwardGravityForce); } if (inputHandler.pressedJump && playerManager.grounded) { playerManager.rig2D.AddForce(transform.up * jumpForce,ForceMode2D.Impulse); } }
إطالة القفزة
في حال ما أبقى اللاعب الزر معلقًا (مضغوطًا)، سنطيل زمن القفز، وذلك عبر عدم تطبيق قوة الجاذبية الإضافية، وبدلاً من ذلك نطبق قوة متجهة للأعلى لجعله يصل لمستوى أعلى.
وأيضًا سنحدد زمن لإطالة القفزة، بعده ستعود الشخصية للأرض حتى إذا أبقى الزر مضغوطًا.
في الملف البرمجي PlayerInput، سنضيف خاصية مسؤولة عن تعليق زر القفز:
public bool isPressingJump { get; private set; } public void CheckContinuousInput(){ ... //الدالة GetButton //ترجع القيمة true ما دام العنصر مستخدمًا isPressingJump = Input.GetButton(jumpInputName); ... }
ثم في الملف البرمجي PlayerMovement. نضيف متغير يخبرنا أنه تم القفز.
ونقوم بتطبيق القوة إضافية للأعلى مادام زر القفز مضغوطًا، وإلا نطبق قوة الجاذبية:
[SerializeField] float maximumJumpTime = 0.25f; //قوة إضافية تطبق للأعلى لرفع الشخصية عاليًا عند الضغط بشكل مستمر على زر القفز [SerializeField] float highJumpForce = 5f; ... bool hasJumped; float jumpTimer; ... public void DoJump() { ... if (playerManager.grounded && hasJumped && jumpTimer >= maximumJumpTime) hasJumped = false; bool applyGravityForce = !playerManager.grounded; if (hasJumped){ jumpTimer += Time.deltatime; //إذا كان الزر معلقًا وما زال هناك وقت كافي لإطالة القفزة if (jumpTimer < maximumJumpTime && inputHandler.isPressingJump){ //قم بتطبيق قوة إضافية لرفعه للأعلى playerManager.rig2D.AddForce(transform.up * highJumpForce); //لا تفعل تطبيق قوة الجاذبية الإضافية applyGravityForce = false; } else // وإلا فعل متغير تطبيق قوة السحب للأسفل (الجاذبية الإضافية) applyGravityForce = true; } if (applyGravityForce){ ... } if (inputHandler.pressedJump && playerManager.grounded && !hasJumped) { playerManager.rig2D.AddForce(transform.up * jumpForce,ForceMode2D.Impulse); hasJumped = true; jumpTimer = 0; } }
النتيجة أفضل بكثير الآن:

الارتداد عن الجدران
للارتداد عن جدار أو حائط، يجب أن تتحقق أربعة شروط:
- تشبث الشخصية على جدار على أحد الجانبين
- يجب أن يكون الجدار موازي للشخصية
- ضغط زر الحركة الأفقية في اتجاه عكس جانب الجدار
- ضغط زر القفز في نفس الوقت
إذا تحققت هذه الشروط، نقوم بـ:
- تطبيق قوة للارتداد
- عدم الاستجابة لتغيير السرعة من قبل اللاعب إلا بعد مرور بعض الوقت من ارتداده.
التأكد من وجود جدار على الجانب
يمكننا استخدام الـRaycast أيضًا هذه المرة، ولكنني سأستخدم أسلوبًا مختلفًا، فبما أن نسخة الـUnity لدينا هي 2019.1.4، يمكننا الاستفادة من ميزة جديدة أضيفت لمحرك الفيزياء PhysX 3.4
وهي قائمة التصادمات أو الملامسات، الـContactsPoint. هذه الخاصية موجودة في العنصر Rigibody2D، وهي تحتوي على جميع ملامسات أي مصادم للـجسم مع أي مصادم كائن آخر.
بمعنى آخر، أي جسم تلمسه شخصيتنا في هذه اللحظة.
من ناحية الأداء، قد لا يكون هناك فرقًا كبيرًا بين اعتماد هذه القائمة بدلاً من الـRaycasts، لكن بالتأكيد لن يضر أبدًا بل قد تكون القائمة أفضل من هذه الناحية.
بعد الحصول على القائمة، سنبحث فيها عن مصادم جدار، وذلك يكون عن طريق الحصول على ما يسمى بالـNormal.
وهذه صورة توضيحية لمفهوم عمود السطح Surface Normal:

يتضح من ذلك أننا نريد البحث عن نقطة على سطح لها ناظم يتجه بشكل أفقي إما لليمين أو اليسار.
في الملف البرمجي PlayerManager سنقوم بكتابة دالة جديدة تتأكد من وجود جدارعلى جنب اللاعب:
// خاصية تخبرنا ما إذا كان هناك جدار على الجانب public bool foundWall{get; private set;} ... void FixedUpdate() { ... //نعين القيمة لسالب (خاطئة) في كل إطار، لأن الافتراضي هو عدم وجود جدار grounded = false; foundWall = false; inputHandler.CheckContinuousInput(); CheckGrounded(); //نستدعي دالة التأكد من وجود جدار CheckWallColliding(); playerMover.UpdateMovement(); } ... //قائمة نستعملها لأخذ القائمة بالأجسام الملامسة للشخصية. //ننشئها هنا لتجنب الـAllocations أو تفريغ السعة أثناء عمل اللعبة. List<ContactPoint2D> contactPoints = new List<ContactPoint2D>(); //دالة تتأكد من وتعين خاصية وجود جدار على الجانب private void CheckWallColliding() { //لن نعتبر أنه يوجد جدار ما دمنا على الأرض if (grounded) return; //نحصل على القائمة ونعينها للقائمة الخاصة بنا List rig2D.GetContacts(contactPoints); //لكل عنصر في القائمة for(int c =0; c < contactPoints.Count; c++) { //التأكد من أن الجسم الذي وجدنا هو من ضمن طبقات الأرضـية. //شرح هذا السطر موجود في الأسفل if (groundLayers == (groundLayers | (1 << contactPoints[c].collider.gameObject.layer))){ // إذا كان الناظم متجه لليمين أو اليسار if(contactPoints[c].normal == Vector2.right || contactPoints[c].normal == Vector2.left){ foundWall = true; //نخرج من حلقة التكرار break; } } } }
شرح سطر التأكد من الطبقة ومعاملات الـBitwise
groundLayers == (groundLayers | (1 << contactPoints[c].collider.gameObject.layer))
أولاً، الـgroundLayers هي طبقات الأرضية كما اتفقنا، وال contactPoints[c].collider.gameObject.layer هي طبقة الجسم أو المصادم الذي لامسته الشخصية.
نحن نريد التأكد من أن هذه الطبقة (سأسميها CollLayer) هي من ضمن طبقات الأرضية.
لاحظوا أن المتغير groundLayers هو عبارة عن رقم صحيح int، هذا الرقم يمثل الطبقات المستعملة.
من الممكن استعمال 32 طبقة مختلفة كحد أقصى في المحرك، وهذا الرقم يشملها كلها. وتذكروا أن الـint يتكون من 32 بت bit، وال bit يكون إما 0 أو 1.
لذلك ببساطة، الـgroundLayers يحتوي على 32 قيمة، كل قيمة مرتبطة بطبقة معينة، وتكون 1 إذا كانت الطبقة مضمنة، أو 0 إذا لم تكن كذلك.
مثلا إذا كانت طبقة الأرضية هي الثامنة في ترتيب الطبقات، وهذا المتغير لا يتضمن سوى هذه الطبقة، نحصل على:
0000 1000 0000 0000 0000 0000 0000 0000
لاحظوا القيمة الثامنة هي 1 في الترتيب من اليمين إلى اليسار.
الأصفار التي على اليسار لا حاجة لها، لذا يختصر إلى:
0000 1000
(هذا الرقم يمثل 128، ولكن ذلك لا يهمنا ).
جميع الكائنات في المشروع لديها خاصية layer وهي عدد صحيح يمثل ترتيب الطبقة.
لأجسام الأرضية، سيكون لهذا الرقم القيمة 8= CollLayer (لأن ترتيب طبقة الأرضية هو الثامن)
وهو، في نظام البتات:
1000
لذلك العملية
CollLayer >> 1
تصبح
8 >> 1
أي تحرك الرقم 1 ثمان مرات لليسار:
0001 تصبح: 0 0000 1000 (أضفنا أصفار إلى اليمين).
العملية OR | تقوم بمقارنة كل بت (رقم)، وإذا كان 1 على أي من الجانبين، نحصل على 1 في النتيجة.
وفي مثالنا، العملية: 0 0000 1000 | 0000 1000
تعطينا 0000 1000، وهي مطابقة للقيمة الأصلية 0000 1000 للـ groundLayers.
لذلك نحصل على قيمة صحيحة، ويتم اعتبار المصادم بأنه ضمن طبقات الأرضية (اعتبرنا أن هذه الطبقة للجدران أيضًا)
ضغط زرّي القفز والتحكم في نفس الوقت
سنقوم الآن بالاستجابة لضغط اللاعب على الزرين jumpButton و الـ horizontalMovement.
أولاً في الملف البرمجي PlayerInput سنضيف خاصية تخبرنا ما إذا تم الضغط على زر الحركة الأفقية (الأسهم).
... public bool pressedHorizontalInput { get; private set; } ... public void CheckClickInput(){ ... pressedHorizontalInput = Input.GetButtonDown(movingInputName); } public void CheckContinuousInput(){ ... if (!pressedHorizontalInput) pressedHorizontalInput = Input.GetButtonDown(movingInputName); }
بماذا تختلف هذه الخاصية عن الخاصية horizontalMovingInputValue؟
تختلف هذه الخاصية pressedHorizontalInput عن الخاصية السابقة horizontalMovingInputValue بأن الأخيرة عبارة عن رقم يمتد من 1- إلى 1، أما الجديدة فهي تعطينا القيمة true في الإطار الذي ضغط اللاعب فيه على الزر.
لماذا لا نستخدم الخاصية القديمة؟ حيث يمكننا التحقق ما إذا كان الرقم ليس صفرًا، مما يعني أن اللاعب يتحرك؟
هذا صحيح، لكن المشكلة أنه عند تغيير الاتجاه من الموجب إلى السالب أو العكس، فالقيمة ستمر بالرقم 0 وتتوقف عنده للحظة (إطار). لذا عند التحقق، قد نحصل على القيمة 0، مع أن اللاعب ضاغط على الزر ويريد الارتداد عن الجدار.
بعد أن نحصل على إشارة بأن اللاعب قد ضغط الزرين، يجب أن نقوم بأمرين:
- تحديد اتجاه الارتداد. للتأكد أنه متناسب مع اتجاه الزر الذي ضغطه اللاعب، لذا سنستخدم عمود السطح normal للنقطة التي تقاطعنا معها على الجدار.
- نطبق الارتداد عن الجدار. ثم نمنع اللاعب من تغيير السرعة مباشرة، وإلا فإنه إذا ترك ضغط الزر بشكل مطول، ستتوقف الشخصية في منتصف التحليق وتهبط سريعًا.
سنقوم بتعيين ذلك باستخدام متغير زمن (مدة)، بعد مرور المدة المحددة، نعود بالسماح للاعب بالتحكم بحرية.
الحصول على اتجاه عمود سطح – المتجه العامودي – للجدار
أولاً، في الملف البرمجي PlayerManager نضيف خاصية متجهة Vector تخبرنا بعامود سطح الجدار الذي لامسناه.
نعين هذه الخاصية بداخل الدالة CheckWallColliding:
... public Vector3 wallHitSurfaceNormal { get; private set; } void CheckWallColliding(){ ... for(int c =0; c < contactPoints.Count; c++) { if (groundLayers == ...))){ if(contactPoints[c].normal == ...){ foundWall = true; wallHitSurfaceNormal = contactPoints[c].normal; } } } }
تطبيق قوة الارتداد
في الملف البرمجي PlayerMovement، في دالة القفز، نتأكد ما إذا تم الضغط على الزرين.. ثم نحدد الاتجاه و نقوم بتطبيق قوة الارتداد:
... [Header("Jumping Off Walls")] //مقدار سرعة اندفاع الشخصية عند ارتدادها من الجدار [SerializeField] float jumpOffWallImpulse = 1f; public void DoJump() { ... if (hasJumped && !applyGravityForce) { ... } if (applyGravityForce){ ... } //عند الضغط على الزرين، ووجود جدار على الجانب: if (inputHandler.pressedJump && inputHandler.pressedHorizontalInput && playerManager.foundWall) { //تستخدم لحساب الإزاحة بين المتجه الأيمن ومتجه عامود - ناظم - السطح أو الجدار //إذا كانت القيمة سالبة، فهذا يعني أن المتجه العامودي متجه لليسار float normalRightDot = Vector2.Dot(playerManager.wallHitSurfaceNormal, Vector2.right); //إذا كان الاتجاه الذي يريده اللاعب هو في نفس اتجاه المتجه العامودي على الجدار if (x_axis < 0 == normalRightDot < 0) { //متجه يتوسط بين الأعلى واليمين Vector2 reflectionVector = Vector2.right + Vector2.up; //نتأكد أن طول المتجه 1 في البداية if(reflectionVector.sqrMagnitude != 1) //هذه العملية تبقي الاتجاه كما هو وتجعل الطول 1 reflectionVector.Normalize(); //إذا كنا نتجه لليسار if (x_axis < 0) //نعكس إشارة المحور x الأفقي reflectionVector.x = -reflectionVector.x; //نعين السرعة بأنها المتجه مضروب بقيمة الاندفاع playerManager.rig2D.velocity = reflectionVector * jumpOffWallImpulse; } } }
منع اللاعب من التحكم بعد الارتداد – وإضافة فاصل زمني بين كل ارتداد
في نفس الملف PlayerMovement،نقوم بتعطيل الدالة MoveHorizontally،إذا قام اللاعب بالارتداد عن الجدار.
بعد مرور المدة الزمنية، نعيد تفعيلها.
وذلك عن طريق إضافة متغير جديد jumpedOffWall:
... المدة بعد الارتداد التي لا يمكن للاعب التحكم بحرية فيها [SerializeField] float timeBetweenJumpsOffWalls = 0.25f; ... float lastJumpOffWallTime; ... bool jumpedOffWall; ... public void UpdateMovement() { //يجب أن يكون هذا السطر في بداية هذه الدالة //بعد مرور المدة الزمنية المحددة: if (jumpedOffWall && Time.time - lastJumpOffWallTime > timeBetweenJumpsOffWalls) jumpedOffWall = false; ... } public void MoveHorizontally() { //لا نسمح له بالتحكم بالسرعة بعد الارتداد مباشرة if (jumpedOffWall) return; ... } public void DoJump() { ... if (!jumpedOffWall && inputHandler.pressedJump && inputHandler.pressedHorizontalInput && playerManager.foundWall) { ... if (x_axis < 0 == normalRightDot < 0) { ... //نعين المتغيرات للاستفادة منهم في القفزة القادمة jumpedOffWall = true; lastJumpOffWallTime = Time.time; } ... } if (inputHandler.pressedJump && playerManager.grounded && !hasJumped && !jumpedOffWall) { ... } }
سنحصل على نتيجة كهذه:

لدينا مشكلتان هنا:
الأولى أن ارتفاع القفزة منخفض نسبيًا، فعلى هذا المنوال، سنأخذ سنة حتى نصل إلى أعلى الجدار !
والثانية، وكما لاحظ كل من جرب اللعبة والملفات البرمجية الآن، بأنه من الصعب الارتداد على الجدار.
ففي معظم الأوقات، تضغط الزرين، القفز والحركة، ولكن لا يحدث شيء، وفي بعض الأحيان الأخرى يرتد ويقفز بشكل صحيح.
هذه المشكلة تحدث بشكل عشوائي و تبدو وكأنها متعلقة بفقدان أوامر الإدخال والتحكم.
1) إطالة قفزة الارتداد للأعلى
كل ما علينا فعله هو تغيير طفيف على متجه السرعة المنعكسة reflectionVector بداخل الدالة DoJump.
حيث سنزيد مركبة المحور الرأسي y للمتجه،.
سنضيف متغير يقوم بهذه المهمة ، نسميه jumpOffWallUpBias. ثم نستخدم المتجه الرأسي Vector2.up للحصول على إزاحة رأسية.
ولجعل النظام مرنًا، سنقوم بضرب المتجه الرأسي ب ( jumpOffWallUpBias + 1 )، وبعد ذلك نضيفه إلى المتجه reflectionVector .
هذا يعني أنه إذا كان المتغير 0، لا يحصل أي تغيير، وإذا كان 1، يتضاعف طول المحور الرأسي ضعفين، وإذا كان 2 ثلاث أضعاف، وهكذا..
في الملف البرمجي PlayerMovement:
... [SerializeField] float jumpOffWallUpBias = 1f; ... public void DoJump() { ... if (!jumpedOffWall ...) { if (x_axis < 0 == normalRightDot < 0){ Vector2 reflectionVector = Vector2.right + Vector2.up * (1 + jumpOffWallUpBias); ... } ... } }
2) حل مشكلة فقدان عنصر الإدخال – مرة أخرى
المشكلة هذه المرة مختلفة، فهي تحدث فقط عندما نريد أن نحصل على قيمة أكثر من زر في نفس الوقت.
تحديدًا، الدالة GetButtonDown، التي استخدمناها لاكتشاف أمر القفز والحركة، تعطي قيمة صحيحة فقط في الإطار الذي حدث فيه الضغط..
وما نفعله نحن هو التأكد أن أمر القفز والحركة حدثا في الإطار ذاته، مما يعني أنه على اللاعب ضغط الزرين في الوقت نفسه واللحظة نفسها دون أي فرق.
لذلك إإذا كانت اللعبة تعمل ب 60 إطار في الثانية، فكل إطار سيأخذ 0.016 ثانية تقريبًا، تخيل أنه لا يجب أن يكون الفرق أكبر من هذا.
والمشكلة تزيد كل ما زاد عدد الإطارات لأن الفرق بين الإطارات يقل.
ما الحل؟
للأسف، لا يوجد حل جاهز من قبل المحرك Unity في الوقت الحالي، هناك نظام جديد للتحكم لكنه ما زال في طور التطوير ولا يجب استعماله للإنتاج.
لكن هذا لا يعني أنه لا يوجد حل، سنقوم بتطبيق فكرة لحل المشكلة بشكل خاص، وهي تأخير الاستجابة للقيمة السالبة.
ببساطة، بعد أن يضغط اللاعب على زر القفز (أو زر الحركة) نقوم بتعيين خاصية جديدة بالقيمة true، ونبقي هذه الخاصية بهذه القيمة لمدة أطول من إطار واحد.
هذه المدة تحدد بالثواني. ولكل زر من الزرين سنقوم بإنشاء خاصية، في حالتنا، الأولى للقفز والثانية للحركة.
بعد مرور هذه المدة، نرجع قيمة هاتين الخاصيتين للقيمة الحالية المستلمة من عنصر الإدخال.
لتسهيل الأمور، سنقوم بفعل ذلك في الملف البرمجي PlayerInput:
//المدة الزمنية التي يجب أن يضغط خلالها اللاعب على الزرين [SerializeField] float multipleKeysDelay = 0.1f; //خاصيتان بديلتان عن الـpressedJump والـpressedHorizontalInput //لكن لا تقوموا بحذفهما، يجب ان تكون الخصائص الأربعة موجودة. public bool delayedPressedJump { get; private set; } public bool delayedHorizButtonPressed { get; private set; } //متغير بدء عملية تأخير الاستجابة وإبقاء الخاصية بالقيمة الصحيحة true private bool delayCheckStarted; //متغير الزمن الذي بدأت فيه عملية التأخير private float delayCheckStartTime; public void CheckClickInput() { ... CheckMultipleKeysInputs(); } public void CheckMultipleKeysInputs() { //إذا تم الضغط على زر القفز (أو الحركة) ولم يتم تعيين الخاصية المتأخرة للقيمة الصحيحة true if (pressedJump && !delayedPressedJump) delayedPressedJump = true; if (pressedHorizontalInput && !delayedHorizButtonPressed) delayedHorizButtonPressed = true; //في حال تم ضغط أي من الزرين (الحركة والقفز) if (delayedPressedJump || delayedHorizButtonPressed){ //نبدأ عملية التأخير if (!delayCheckStarted){ //نحدد متغيرات البدء. delayCheckStartTime = Time.time; delayCheckStarted = true; } } //إذا بدأت عملية التأخير if (delayCheckStarted){ //تتحقق ما إذا مرت المدة الزمنية المحددة if (Time.time - delayCheckStartTime > multipleKeysDelay){ //أعطي الخصائص القيم المستلمة من عناصر الإدخال delayedPressedJump = pressedJump; delayedHorizButtonPressed = pressedHorizontalInput; delayCheckStarted = false; } } }
بعد ذلك، عندما نتأكد من أن اللاعب يضغط على الزرين، نستخدم الخاصيتين الجديدتين.
في الملف البرمجي PlayerMovement:
public void DoJump() { ... if (!jumpedOffWall && inputHandler.delayedPressedJump && inputHandler.delayedHorizButtonPressed && playerManager.foundWall){ ... } ... }
هكذا أصبح القفز أفضل بكثير:

خطوة اختيارية: تصعيب تغيير الاتجاه أثناء القفز
في ألعاب كثيرة مثل Super Mario Bros القديمة، نلاحظ أنه بعد القفز، لا يسهل على ماريو تغيير اتجاه حركته، ولا يمكنه تغيير اتجاهه أيضًا.
وهذه خطوة اختيارية، تعتمد على مطور ومصمم اللعبة، إذا كان يريد تطبيق قيد كهذا أو لا.
سنقوم بتطبيق ذلك الآن للشمولية، في الملف البرمجي PlayerMovement:
... public void UpdateMovement() { ... if (IsDirectionFlipped(x_axis)){ //إذا كان اللاعب على الأرض أو ارتد عن جدار الآن أو ملامس لجدار if (playerManager.grounded || jumpedOffWall || playerManager.foundWall){ // نسمح بتغيير الاتجاه transform.localScale = Vector3.Scale(transform.localScale, new Vector3(-1, 1, 1)); isFacingRight = !isFacingRight; } } ... } public void MoveHorizontally() { ... float x_vel = x_axis * walkSpeed; if(inputHandler.isHoldingRunInput) x_vel = x_axis * runSpeed; //إذا لم تكن الشخصية ملامسة للأرض if (!playerManager.grounded){ //نجعل السرعة المطلوبة بأنها القيمة الوسطى بين السرعة الحالية والسرعة المطلوبة x_vel = (playerManager.rig2D.velocity.x + x_vel) / 2f; } ... }
الآن نكون قد انتهينا من برمجة حركات القفز والارتداد عن الجدار، وسوف نقوم في المقال القادم إن شاءالله ببرمجة حركة للكاميرا لتتبع الشخصية أينما ذهبت.
أتطلع لأرائكم وأسئلتكم في التعليقات، ولا تترددوا بإخباري عن أي مشكلة لاحظتموها في المقال.