الفصل الثاني: المركبات الفيزيائية

العب

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

الشكل 52: سيارة بسيطة تم تركيبها باستخدام الأشكال الأساسية

الشكل 52: سيارة بسيطة تم تركيبها باستخدام الأشكال الأساسية

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

الشكل 53: إضافة أجزاء المركبة كأبناء لكائن فارغ

الشكل 53: إضافة أجزاء المركبة كأبناء لكائن فارغ

سنضيف الآن مكوّن التصادم لهذا الكائن الفارغ الذي أضفناه، سنستخدم لهذا الغرض مكوّنا على شكل كبسولة Capsule Collider والذي يتميز بأنه يمنع السيارة من أن تقف على ظهرها حال انقلابها وبالتالي تستمر في الدحرجة حتى تعود لتقف على عجلاتها مجددا. لأجل ذلك يجب أن تضاف هذه الأسطوانة بحيث تمتد على طول السيارة من الأمام للخلف، وأن ترتفع قليلا عن مستوى الأرض إلى أن يلامس سطحها السفلي الجزء الأسفل من جسم السيارة. الخطوة المهمة الأخرى فيما يتعلق بالتصادم هو أن نزيد حجم مكوّنات التصادم على المحورين الأمامي والخلفي، ويجب أن تكون هذه الزيادة تحديدا على المحور x مما يجعل مكوّن التصادم الخاص بكل محور أطول من المحور نفسه: فالمحور الأصلي (المرئي) ينتهي عند الأوجه الداخلية للعجلات، بينما يجب أن يمتدّ طول مكوّن التصادم ليلامس الأسطح الخارجية للعجلات. إن كنت تتساءل عن أهمية هذه الخطوة، فهي بالتجربة ضرورية لتجنب أن تغوص العجلات عرضيا تحت سطح الأرض وبالتالي تعلق السيارة في حالة لا يمكن التحكم بها وهو ما لا نريده. بالتالي في هذه الحال إذا سقطت السيارة على جانبها فإنّ أول ما يلامس الأرض هو مكوّن التصادم الخاص بالمحور فيوقف عملية السقوط قبل أن تنزل العجلة تحت مستوى سطح الأرض. الشكل 54 يوضح الإعداد الصحيح لمكوّن التصادم الخاصة بالسيارة.

الشكل 54: إضافة مكوّن تصادم على شكل كبسولة إلى كائن السيارة

الشكل 54: إضافة مكوّن تصادم على شكل كبسولة إلى كائن السيارة

المكوّن الآخر الذي ينبغي علينا أن نضيفه للكائن الجذري لسيارتنا هو الجسم الصلب Rigid Body. سنحتاج لكتلة واقعية تناسب سيارة بهذا الحجم ولتكن مثلا 1500 كيلوجرام. سنحتاج أيضا لمقاومة هواء عالية نسبيا بحيث تعطي اللاعب شعورا بثقل وزن السيارة حين قيادتها حيث يجب أن تتوقف بعد مسافة قصيرة من توقف الضغط على دواسة الوقود. سنحتاج لهذا الأمر القيمة 0.25 لمتغير مقاومة الهواء Drag وقيمة 0.75 لمقاومة الهواء الخاصة بالدوران Angular Drag. أمر آخر مهم وهو تغيير موقع مركز الثقل الخاص بالسيارة والذي يضعه Unity افتراضيا في مركز الكائن، بيد أننا هنا نريد أن نمنع الانقلاب السهل للسيارة عند الانعطاف بالتالي سنقوم بتحريك مركز الثقل نحو الأسفل قليلا. قم بإضافة كائن فارغ آخر داخل السيارة وأسمه CenterOfMass ثم قم بتحريكه ليكون في الموقع (0.2 ,0.5- ,0) داخل جذر السيارة الرئيسي. سنقوم لاحقا باعتماد موقع هذا الكائن كمركز للثقل من خلال بريمج خاص بهذا الغرض.

سنحتاج الآن لعجلات حقيقية الأداء لسيارتنا، حيث أن العجلات تعتبر الجزء الأهم في عملية محاكاة المركبات الفيزيائية. السبب في ذلك هو أننا من خلال هذه العجلات سنكون قادرين على تطبيق عزم المحرك والمكابح والتوجيه إضافة إلى النوابض. من الأمور الخاصة بمحرك Unity والتي قد لا تنطبق بالضرورة على غيره هو عملية الفصل بين كائن العجلة المرئية وكائن محاكاة العجلة المقابل له. من أجل ذلك علينا أن نضيف أربع كائنات فارغة أخرى للسيارة ومن ثم نضيف لكل منها مكوّن محاكاة العجلة ويسمى Wheel Collider، مما يترك العجلات المرئية دون أي مكوّن تصادم.

يفضّل عند إضافة مكوّنات محاكاة العجلات أن تتم إضافتها داخل كائن فارغ آخر بداخل السيارة، بحيث يحتوي هذا الكائن فقط على أربعة أبناء يمثل كل منها عجلة من عجلات السيارة ويحمل مكوّنا واحدا فقط هو Wheel Collider. بما أن سماكة مكوّن تصادم العجلات في Unity هي صفر، فسنحتاج لإضافة اثنين من هذه المكوّنات على كل عجلة من عجلات السيارة العريضة نوعا ما، بحيث يكون أحدها على الوجه الخارجي للعجلة والآخر على الوجه الداخلي لها. معنى ذلك أننا سنضيف كائنا فارغا كابن للسيارة ولنسمه مثلا WheelColliders ومن ثم سنضيف له ثمانية كائنات فارغة أخرى كأبناء ونضيف لكل منها مكوّن تصادم العجلات Wheel Collider كما في الشكل 55.

الشكل 55: كائنات فارغة تستخدم لإضافة مكوّنات تصادم العجلات. لاحظ أن الأسماء تمثل مواقع المكوّنات بالنسبة للعجلات المرئية

الشكل 55: كائنات فارغة تستخدم لإضافة مكوّنات تصادم العجلات. لاحظ أن الأسماء تمثل مواقع المكوّنات بالنسبة للعجلات المرئية

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

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

الشكل 56: ضبط قيم مكوّن تصادم العجلات

الشكل 56: ضبط قيم مكوّن تصادم العجلات

لدينا هنا مجموعة من الخصائص ذات الأهمية مثل كتلة العجلة والتي قمنا بضبطها على 15، وبما أننا نستخدم مكوّني تصادم لكل عجلة، فستكون الكتلة الإجمالية لكل عجلة من عجلات السيارة هي 30. بالنسبة لنصف القطر radius يمكننا معايرته يدويا بحيث ينطبق على العجلة المرئية للسيارة. أخيرا لدينا مسافة حركة النابض والتي ضبطناها على 0.25 أي 25 سنتيمترا في الحالة غير المضغوطة للنابض. هناك عدد من فئات المتغيرات التي تحتوي على قيم مهمة لعملية المحاكاة مثل Suspension Spring و Forward Friction والتي قد تحتاج أحيانا وقتا طويلا للتجربة حتى تجد القيم المناسبة. يمكنك الاطلاع على ملفات مساعدة Unity وبعض مصادر الإنترنت لمعرفة الطريقة الأمثل لضبط هذه القيم. بعد الانتهاء من عملية الضبط يجب وضع مكوّنات التصادم في مواقعها الصحيحة كما في الشكل 57.

الشكل 57: ضبط مكوّنات تصادم العجلات في مواقعها الصحيحة حيث تظهر المكوّنات باللون الأبيض

الشكل 57: ضبط مكوّنات تصادم العجلات في مواقعها الصحيحة حيث تظهر المكوّنات باللون الأبيض

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

الشكل 58: التركيبة النهائية لكائن السيارة

الشكل 58: التركيبة النهائية لكائن السيارة

بهذا تصبح السيارة جاهزة لنتحكم بها، وبالتالي نحتاج لمجموعة من البريمجات وأولها PhysicsCarDriver والذي يظهر في كل من السرد 41 والسرد 42 والسرد 43. يمثل هذا البريمج الوظائف الأساسية للسيارة منفصلة عن كل ما له علاقة بمدخلات اللاعب، مما يجعله قابلا للاستخدام من قبل بريمجات أخرى كالذكاء الاصطناعي مثلا. نظرا لطول البريمج قمت بتقسيمه على 3 أجزاء حيث يظهر الجزء الأول منه في السرد 41، والذي يمثل المتغيرات التي نحتاجها.

1. using UnityEngine;
2. using System.Collections;
3. 
4. public class PhysicsCarDriver : MonoBehaviour {
5.  
6.  //مكوّنات تصادم العجلات الأمامية
7.  public WheelCollider[] frontWheels;
8.  
9.  //مكوّنات تصادم العجلات الخلفية
10.     public WheelCollider[] backWheels;
11.     
12.     //مركز ثقل السيارة
13.     public Transform centerOfMass;
14.     
15.     //العزم الأقصى للمحرك
16.     public float maxMotorTorque = 9500;
17.     
18.     //عزم المكابح
19.     public float brakesTorque = 7500;
20.     
21.     //أقصى زاوية استدارة لتوجيه العجلات يمينا ويسارا 
22.     public float maxSteeringAngle = 20;
23.     
24.     //سرعة استدارة العجلات لليمين اليسار أثناء التوجيه
25.     public float steeringSpeed = 30;
26.     
27.     //السرعة القصوى للسيارة مقدرة بـ كم \ ساعة
28.     public float maxSpeed = 250;
29.     
30.     //السرعة القصوى للرجوع للخلف
31.     public float maxReverseSpeed = 20;
32.     
33.     //زاوية دوران توجيه العجلات الحالية
34.     float currensSteering = 0;
35.     
36.     //سرعة السيارة القصوى مقدرة بدورة في الدقيقة
37.     float maxRPM, maxReverseRPM;
38.     
39.     //متغيرات استقبال المدخلات
40.     bool accelerateForward, 
41.         accelerateBackwards, 
42.         brake, steerRight, steerLeft;
43.     

السرد 41: المتغيرات التي سنحتاج إليها في بريمج التحكم بالسيارة

لمر بشكل سريع على هذه المتغيرات التي نراها. لدينا في البداية مصفوفتان من نوع WheelCollider والتي سنستخدمها لتخزين كافّة العجلات الفيزيائية للسيارة. الهدف من فصل العجلات الأمامية والخلفية عن بعضها هو أننا سنقوم بعملية التوجيه باستخدام العجلات الأمامية فقط وهذا هو الحال في السيارات الحقيقية كما نعرف. المتغير الثاني وهو مركز ثقل السيارة centerOfMass والذي سنستخدمه كمرجع لكائن فارغ قمنا سابقا بإضافته بهدف إزاحة مركز ثقل السيارة نحو الأسفل لمنعها من الانقلاب عند الانعطاف. نأتي بعد ذلك على عزم كل من المحرك والمكابح maxMotorTorque و brakesTorque، والعزم فيزيائيا مقياس لحساب قدرة قوة معيّنة على تحريك جسم ما بشكل دائري، وهو تماما ما يفعله كل من المحرك والمكابح بالعجلات، مع فرق أنّ عزم المكابح يسعى لإيقاف السيارة وذلك بإلغاء عزم المحرك. المتغيران maxSteeringAngle و steeringSpeed يساعداننا في عملية التوجيه، وسنستخدمهما بطبيعة الحال مع العجلات الأمامية فقط. أخيرا وليس آخرا لدينا متغيرا السرعة القصوى نحو الأمام والخلف maxSpeed و maxReverseSpeed والذان يحددان أقصى سرعة يمكن أن تصلها السيارة بوحدة كم\ساعة.

بالإضافة للمتغيرات العامّة التي ذكرناها آنفا لدينا مجموعة من المتغيرات الخاصة بالاستخدام الداخلي للبريمج. أولها هو currentSteering والذي نستعمله لتخزين زاوية التوجيه الحالية للعجلات الأمامية، إضافة إلى maxRPM و maxReverseRPM اللذان يمثلان السرعة القصوى أماما وخلفا لكن بوحدة أخرى هي دورة\دقيقة. لماذا نحتاج لمعرفة السرعة بهذه الوحدة تحديدا؟ الجواب بسيط وهو أنّ مكوّن تصادم العجلات Wheel Collider لا يتعامل إلّا مع هذه الوحدة. أخيرا لدينا مجموعة من المتغيرات الخاصّة بحالة المدخلات، والتي نعرف من خلالها كيف يتم التحكم بالسيارة حاليا، فمثلا قيمة true للمتغير accelerateForward تعني أنّ اللاعب يضغط على دوّاسة الوقود بالتالي يجب أن تتحرك السيارة للأمام، وهذه الطريقة تنسحب على باقي متغيرات التحكم الأخرى. الجزء الثاني من البريمج موجود في السرد 42، والذي يوضّح الدّالتين ()Start و ()FixedUpdate.

44.     void Start () {
45.         //احسب السرعة القصوى بوحدة دورة\دقيقة
46.         maxRPM = 
47.             KmphToRPM(frontWheels[0], maxSpeed);
48.         maxReverseRPM = 
49.             KmphToRPM(frontWheels[0], maxReverseSpeed);
50.         
51.         //قم بضبط مركز الثقل الخاص بالجسم الصلب للسيارة
52.         rigidbody.centerOfMass = 
53.             centerOfMass.localPosition;
54.     }
55.     
56.     //Update() لتحديث الإطارات بدلا من FixedUpdate() عند التعامل مع الفيزياء تستخدم الدّالة
57.     void FixedUpdate () {
58.         //تحديث التسارع نحو الأمام أو الخلف حسب قيم متغيرات الإدخال
59.         if(accelerateForward){
60.             foreach(WheelCollider wheel in frontWheels){
61.                 UpdateWheelTorque(wheel, maxMotorTorque);
62.             }   
63.             
64.             foreach(WheelCollider wheel in backWheels){
65.                 UpdateWheelTorque(wheel, maxMotorTorque);
66.             }
67.             accelerateForward = false;
68.         } else if(accelerateBackwards){
69.             
70.             foreach(WheelCollider wheel in frontWheels){
71.                 UpdateWheelTorque(wheel, -maxMotorTorque);
72.             }   
73.             
74.             foreach(WheelCollider wheel in backWheels){
75.                 UpdateWheelTorque(wheel, -maxMotorTorque);
76.             }
77.             accelerateBackwards = false;
78.         } else {
79.             foreach(WheelCollider wheel in frontWheels){
80.                 UpdateWheelTorque(wheel, 0);
81.             }   
82.             
83.             foreach(WheelCollider wheel in backWheels){
84.                 UpdateWheelTorque(wheel, 0);
85.             }
86.         }
87.         
88.         //تحديث التوجيه نحو اليمين أو اليسار
89.         if(steerRight){
90.             UpdateSteering(steeringSpeed * Time.deltaTime);
91.             steerRight = false;
92.         } else if(steerLeft){
93.             UpdateSteering(-steeringSpeed * Time.deltaTime);
94.             steerLeft = false;
95.         } else {
96.             UpdateSteering(0);
97.         }
98.         
99.         //تحديث حالة المكابح
100.        if(brake){
101.            foreach(WheelCollider wheel in frontWheels){
102.                wheel.brakeTorque = brakesTorque;
103.            }   
104.            
105.            foreach(WheelCollider wheel in backWheels){
106.                wheel.brakeTorque = brakesTorque;
107.            }
108.            brake = false;
109.        } else {
110.            foreach(WheelCollider wheel in frontWheels){
111.                wheel.brakeTorque = 0;
112.            }   
113.            
114.            foreach(WheelCollider wheel in backWheels){
115.                wheel.brakeTorque = 0;
116.            }
117.        }
118.    }
119.    

السرد 42: الدّالتان ()FixedUpdate و ()Start الخاصّتان ببريمج التحكم بالسيارة

بداية نقوم في الدّالة ()Start بتحويل السرعة من الوحدة المدخلة في المتغيرات العامّة وهي كم\ساعة إلى الوحدة التي سنتعامل بها داخليا وهي دورة\دقيقة، وهذه العملية تتم عبر دالّة قمنا بكتابتها خصيصا لهذا الغرض وهي ()KmphToRPM والتي سنناقشها بعد قليل. نقوم بعدها بتخزين القيم المحوّلة في المتغيرين maxRPM و maxReverseRPM. الخطوة المهمة الثانية التي ننفذها من خلال ()Start هي تحديد مركز ثقل الجسم الصلب التابع للسيارة بحيث يصبح في الموقع المحلي للمتغير centerOfMass والذي سنضبطه لاحقا من نافذة الخصائص ليرتبط بالكائن الفارغ الذي أضفناه ليقوم بهذه المهمة.

لننتقل الآن إلى دورة التحديث والتي نتعامل معها هذه المرة باستخدام ()FixedUpdate وذلك بخلاف ما تعوّدنا عليه من استخدام ()Update. الفرق بينهما هو أنّ ()FixedUpdate تُستَدعى على فترات زمنية ثابتة حتى تعطي أداء دقيقا لمحاكاة الفيزياء واكتشاف التصادمات، بمعنى آخر فإن قيمة
Time.deltaTime هي نفسها في كل مرة تستدعيها من داخل ()FixedUpdate بخلاف ()Update التي تعطيك قيمة مختلفة في كل مرة. من داخل هذه الدّالة نقوم أولا بفحص قيمتي المدخلين accelerateForward و accelerateBackwards (الأسطر 59 إلى 86) وبناء عليها نقوم بتطبيق عزم المحرك على العجلات الأمامية والخلفية مستخدمين الدّالة ()UpdateWheelTorque. مهمة هذه الدّالة كما سنرى في تفاصيلها بعد قليل هي فحص حدود السرعة القصوى قبل تطبيق عزم المحرك من أجل ضمان ألا تتجاوز السيارة سرعتها القصوى التي حددناها، بعد أن يتم تطبيق العزم نعيد ضبط قيمة متغير الإدخال سواء كان accelerateForward أو accelerateBackwards إلى false. أمّا في حالة كانت قيم المدخلات لكلا المتغيرين أصلا false، فإنّ هذا يعني أننا سنطبق عزما بقيمة صفر على العجلات.

نقوم بعد ذلك باستخدام آلية مشابهة لتطبيق التوجيه وذلك في الأسطر 89 إلى 97، حيث نفحص قيمة كل من steerRight و steerLeft، فإذا كانت قيمتها true فإننا نقوم باستدعاء الدّالة ()UpdateSteering ممررين قيم التوجيه المناسبة لكل اتجاه، أو صفرا في حالة كانت قيمة كل من المتغيرين false. سنقوم بعد قليل أيضا بشرح تفصيلي للدّالة ()UpdateSteering وآلية عملها. أخيرا لدينا في الأسطر 101 إلى 117 عملية فحص وتطبيق المكابح، والتي نقوم بها هذه المرة مباشرة عن طريق تغيير قيمة wheel.breakTorque لجميع العجلات لتساوي عزم المكابح المخزن في المتغير breakTorque الخاص بالبريمج. لم نحتج هنا لفحص أية شروط بعكس حالة تطبيق عزم المحرك، ذلك أن المكابح لا تحتاج لأي شروط مسبقة، فهي تعمل فحسب، ولا شيء سيحدث لو ضغطناها والسيارة في حالة وقوف تام. بقي لنا الآن الجزء الأخير من بريمج التحكم بالسيارة والموضح في السرد 43، والذي يحتوي على باقي الدّوال المهمة لإتمام عملية التحكم.

120.    //قد السيارة نحو الأمام
121.    public void AccelerateForward(){
122.        accelerateForward = true;
123.        accelerateBackwards = false;
124.    }
125.    
126.    //قد السيارة للخلف
127.    public void AccelerateBackwards(){
128.        accelerateBackwards = true;
129.        accelerateForward = false;
130.    }
131.    
132.    //أدر عجلة القيادة لليمين
133.    public void SteerRight(){
134.        steerRight = true;
135.        steerLeft = false;
136.    }
137.    
138.    //أدر عجلة القيادة لليسار
139.    public void SteerLeft(){
140.        steerLeft = true;
141.        steerRight = false;
142.    }
143.    
144.    //قم بالضغط على المكابح
145.    public void Brake(){
146.        brake = true;
147.    }
148.    
149.    //تقوم  بفحص حدود السرعة وتطبيق عزم المحرك على العجلات
150.    void UpdateWheelTorque(WheelCollider wheel, float torque){
151.        wheel.motorTorque = torque;
152.        if(wheel.rpm > maxRPM || wheel.rpm < -maxReverseRPM){
153.            wheel.motorTorque = 0;
154.        }
155.    }
156.    
157.    //تقوم بتحديث زاوية توجيه العجلات الأمامية
158.    void UpdateSteering(float amount){  
159.        if(amount != 0){
160.            currensSteering += amount;
161.        } else {
162.            //تم إفلات عجلة القيادة
163.            //في هذه الحالة يجب أن نعيدها للمنتصف بسرعة محددة
164.            //سنستخدم منطقة ميتة نعتبر خلالها أن العجلة عادة للزاوية صفر
165.            //وهي بين 3 و 3- درجات
166.            if(currensSteering > 3){
167.                currensSteering -= 
168.                        steeringSpeed * Time.deltaTime;
169.            } else if(currensSteering < -3){
170.                currensSteering += 
171.                        steeringSpeed * Time.deltaTime;
172.            } else {
173.                currensSteering = 0;
174.            }
175.        }
176.        //قم بالتأكد من عدم تجاوز أقصى زاوية للتوجيه سواء لليمين أو لليسار
177.        if(currensSteering > maxSteeringAngle){
178.            currensSteering = maxSteeringAngle;
179.        }
180.        
181.        if(currensSteering < -maxSteeringAngle){
182.            currensSteering = -maxSteeringAngle;
183.        }
184.        //قم أخيرا بتطبيق الزاوية التي حسبناها على العجلات الأمامية فقط
185.        foreach(WheelCollider wheel in frontWheels){
186.            wheel.steerAngle = currensSteering;
187.        }
188.    }
189.    
190.    //تقوم بالتحويل من كم\ساعة إلى دورة\دقيقة باستخدام
191.    //قيمة نصف قطر العجلة المزودة
192.    float KmphToRPM(WheelCollider wheel, float speed){
193.        //حساب السرعة بوحد متر\ساعة
194.        float mph = speed * 1000;
195.        //حساب السرعة بوحدة متر\دقيقة
196.        float mpm = mph / 60;
197.        return mpm / (wheel.radius * 2 * Mathf.PI);
198.    }
199. }  

السرد 43: الوظائف المساعدة الأخرى الخاصة ببريمج التحكم بالسيارة

عند استدعاء الدّالة ()AccelerateForward فإنّ ما تقوم به هو ضبط قيمة المتغير accelerateForward إلى true وكذلك ضبط accelerateBackwards إلى false منعا لحصول مدخلات متناقضة. نفس هذه الطريقة متّبعة أيضا من قبل ()AccelerateBackwards و ()SteerRight و ()SteerLeft و ()Brake. بالنسبة للدّالة ()UpdateWheelTorque فإنّ ما تقوم به هو أخذ عجلة فيزيائية (أي متغير من نوع WheelCollider) بالإضافة لقيمة عزم لتقوم بتطبيقها على هذه العجلة، بعد تطبيق العزم تقوم بمقارنة السرعة الحالية للعجلة مع كل من القيمة الموجبة للمتغير maxRPM والقيمة السالبة للمتغير maxReverseRPM (القيمة السالبة هنا تعني أن اتجاه الدوران نحو الخلف)، فإذا تجاوزت سرع العجلة هذه الحدود تقوم بإعادة العزم إلى القيمة صفر حتى لا تسمح باستمرار الزيادة في السرعة وتبقيها ثابتة على قيمتها الحالية.

بالنسبة للدّالة ()UpdateSteering فإن دورها هو أن تأخذ قيمة رقمية وهي عبارة عن زاوية ستقوم بإضافتها لزاوية التوجيه الحالية للعجلات الأمامية currentSteering. إذا كانت هذه القيمة صفرا دل ذلك على أنّه لا يوجد أي مدخل للتوجيه بالتالي يجب إعادة عجلة القيادة للمنتصف وتوجيه العجلات نحو الأمام بسرعة محددة وهي steeringSpeed. إذا دخلت زاوية التوجيه المنطقة الميتة أثناء الإعادة وهي المنطقة بين 3 درجات و3- درجات، فإنّنا نقوم بتغيير زاوية التوجيه لحظيا ونعيدها إلى الصفر. بعد إضافة القيمة المزودة عبر المتغير amount إلى قيمة زاوية التوجيه الحالية currentSteering علينا أن نقوم بالتأكد من أنّها تقع ضمن الحدود القصوى لزاوية التوجيه أي بين maxSteeringAngle يمينا و maxSteeringAngle- يسارا، ففي حال تجاوزت الزاوية هذه الحدود نقوم بتعديل القيمة لتصبح مساوية للحد الأقصى المسموح به. أخيرا نقوم بضبط قيمة steerAngle لجميع العجلات الأمامية لتصبح مساوية للزاوية الجديدة التي قمنا بحسابها عبر الخطوات السابقة.

آخر الدوّال التي سنناقشها في هذا البريمج هي ()KmphToRPM والتي تأخذ متغيرين أحدهما عجلة فيزيائية لنستعمل نصف قطرها في حساباتنا والآخر هو قيمة السرعة التي نريد تحويلها، وهي بطبيعة الحال مقدرة بـوحدة كم\ساعة. نقوم في أول خطوة بتحويل السرعة من كم\ساعة إلى متر\دقية وذلك بضرب قيمة السرعة بـ1000 ومن ثم قسمتها على 60 ونخزن الناتج في المتغير mpm. بهذا نكون قد وحدنا القيم حيث أن السرعة التي تستخدمها العجلات الفيزيائية تحسب بالدقيقة ونصف قطر العجلات محسوب بالأمتار. الخطوة التالية هي تحويل السرعة من متر\دقيقة إلى دورة\دقيقة. هذا التحويل يعتمد على محيط العجلة، حيث أنّ طول المحيط هو الذي يحدد كم مترا يمكن للعجلة أن تقطع في كل دورة كاملة. من أجل ذلك علينا أن نحسب محيط العجلة باستخدام قوانين الدائرة المعروفة لدينا، ومن ثم نقسم السرعة على هذا المحيط لتعطينا عدد الدورات في كل دقيقة، وهي بالتحديد القيمة التي نريد أن نرجعها من هذه الدّالة. بعد الانتهاء من هذا البريمج الطويل والمتعب علينا أن نضيفه لجذر كائن السيارة، وبذلك نصبح جاهزين للانتقال للبريمج التالي.

البريمج التالي الذي نحتاج إليه هو الذي يقوم بقراءة مدخلات اللاعب وتمريرها لبريمج التحكم بالسيارة. سيقوم هذا البريمج بقراءة مدخلات لوحة المفاتيح، ومن ثم استدعاء الدوّال المناسبة من بريمج PhysicsCarDriver. السرد 44 يوضح البريمج KeyboardCarController والذي سنستخدمه لقراءة مدخلات اللاعب.

1. using UnityEngine;
2. using System.Collections;
3. 
4. public class KeyboardCarController : MonoBehaviour {
5.  
6.  //متغير لتخزين مرجع لبريمج السيارة التي سنتحكم بها
7.  PhysicsCarDriver driver;
8.  
9.  void Start () {
10.         //قم بالبحث عن البريمج المضاف على نفس الكائن
11.         driver = GetComponent<PhysicsCarDriver>();
12.     }
13.     
14.     void Update () {
15.         //نستخدم السهمين الأعلى والأسفل من أجل الحركة للأمام والخلف
16.         if(Input.GetKey(KeyCode.UpArrow)){
17.             driver.AccelerateForward();
18.         } else if(Input.GetKey(KeyCode.DownArrow)){
19.             driver.AccelerateBackwards();
20.         }
21.         
22.         //نستخدم الأسهم الأيمن والأيسر للاستدارة يمينا وشمالا
23.         if(Input.GetKey(KeyCode.RightArrow)){
24.             driver.SteerRight();
25.         } else if(Input.GetKey(KeyCode.LeftArrow)){
26.             driver.SteerLeft();
27.         }
28.         
29.         //نستخدم مفتاح المسافة للضغط على المكابح
30.         if(Input.GetKey(KeyCode.Space)){
31.             driver.Brake();
32.         }
33.     }
34. }

السرد 44: البريمج الخاص بقراءة مدخلات اللاعب واستخدمها للتحكم بالسيارة

هذا البريمج بسيط جدا كما ترى، فكل ما يفعله هو استدعاء الدّالة المناسبة من البريمج PhysicsCarDriver والموجود فعليا على نفس الكائن وهو هنا الكائن الجذري للسيارة. البريمج الأخير الذي سنضيفه للسيارة هو CarSpeedMeasure والذي مهمته قياس سرعة السيارة الحالية بوحدة كم\ساعة وطباعتها في نافذة المخرجات Console في Unity. هذا البريمج موضح في السرد 45.

يمكنك فتح نافذة المخرجات Console بالضغط بالفأرة على أسفل يسار الشاشة، علما بأن آخر سطر تمت طباعته في هذه الشاشة يظهر دوما بشكل تلقائي أسفل يسار الشاشة

1. using UnityEngine;
2. using System.Collections;
3. 
4. public class CarSpeedMeasure : MonoBehaviour {
5.  
6.  public WheelCollider wheel;
7.  
8.  void Start () {
9.  
10.     }
11.     
12.     void Update () {
13.         //قم بحساب السرعة وطباعتها في نافذة المخرجات
14.         print (GetCarSpeed());
15.     }
16.     
17.     //تقوم بتحويل السرعة من دورة\دقيقة إلى كم\ساعة
18.     //مستخدمة نصف قطر العجلة
19.     float GetCarSpeed(){
20.         //عدد الأمتار المقطوعة في الدقيقة
21.         float mpm = wheel.rpm * wheel.radius * 2 * Mathf.PI;
22.         //عدد الأمتار المقطوعة في الساعة
23.         float mph = mpm * 60;
24.         //عدد الكيلومترات المقطوعة في الساعة
25.         float kmph = mph / 1000;
26.         return kmph;
27.     }
28. }

السرد 45: بريمج لحساب سرعة السيارة بوحدة كم\ساعة وطباعتها في نافذة المخرجات بشكل مستمر

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

بقي الآن أن نضيف بعض التحسينات الجمالية ولكنها أيضا مهمّة. قمنا في الخطوات السابقة بتجهيز السيارة تماما ويمكن للاعب الآن قيادتها والتحكم بها بشكل كامل، إلّا أنه ليس قادرا بعد على رؤية العجلات وهي تدور أو رؤية حركتها عند الاستدارة، كما أنّه ليس قادرا على رؤية حركة جسم السيارة فوق النوابض ارتفاعا وانخفاضا. الملاحظ أن هذه الأمور كلها مرتبطة بعجلات السيارة، إذن كل ما علينا هو كتابة بريمج نضيفه للعجلات المرئية ويحركها بما يناسب الحالة التي عليها العجلات الفيزيائية، بحيث يتسق منظر السيارة مع أدائها. هذا البريمج هو CarWheelAnimator وموضح في السرد 46.

1. using UnityEngine;
2. using System.Collections;
3. 
4. public class CarWheelAnimator : MonoBehaviour {
5.  
6.  //العجلة الفيزيائية التي سنقرأ المعلومات منها
7.  public WheelCollider wheel;
8.  
9.  //الكائن المستعمل كمحور للتوجيه يمينا ويسارا
10.     public Transform steeringAxis;
11.     
12.     //يقوم هذا المتغير بتخزين آخر قيمة لزاوية التوجيه
13.     //وهذا ضروري لإعادة توجيه العجلة نحو الأمام
14.     //مرة أخرى
15.     float lastSteerAngle = 0;
16.     
17.     //لحفظ الموقع الأصلي للعجلة عند البداية
18.     Vector3 originalPos;
19.     
20.     void Start () {
21.         //قم بتسجيل الموقع الأصلي للعجلة عند بداية التشغيل
22.         originalPos = transform.localPosition;
23.     }
24.     
25.     void LateUpdate () {
26.         //نقوم بداية بتحويل السرعة من دورة\دقيقة إلى درجة\ثانية
27.         float rotationsPerSecond = wheel.rpm / 60;
28.         float degreesPerSecond = rotationsPerSecond * 360;
29.         
30.         //yقم بالتدوير حول المحور المحلي 
31.         transform.Rotate(0, degreesPerSecond * Time.deltaTime, 0);
32.         
33.         //محور التوجيه موجود فقط في حالة العجلات الأمامية
34.         if(steeringAxis != null){
35.             //قم بإرجاع الدوران للأمام وذلك عن طريق
36.             //طرح قيمة الدوران المحفوظة من الإطار السابق
37.             transform.RotateAround(
38.                         steeringAxis.position, 
39.                         steeringAxis.up, 
40.                         -lastSteerAngle);
41.             //قم بتطبيق قيمة التوجيه الجديدة
42.             transform.RotateAround(
43.                         steeringAxis.position, 
44.                         steeringAxis.up, 
45.                         wheel.steerAngle);
46.             //قم بتحديث قيمة آخر زاوية توجيه حتى نقوم بطرحها في الإطار المقبل
47.             lastSteerAngle = wheel.steerAngle;
48.         }
49.         
50.         //افحص فيما إذا كانت العجلة تلمس الأرض
51.         WheelHit hit;
52.         
53.         if(wheel.GetGroundHit(out hit)){
54.             //العجلة تلمس الأرض
55.             //قم بتحريك العجلة نحو الأعلى بمقدار مسافة انضغاط النابض
56.             //مستخدما محور الفضاء
57.             float colliderCenter = hit.point.y + wheel.radius;
58.             Vector3 wheelPosition = transform.position;
59.             wheelPosition.y = colliderCenter;
60.             transform.position = wheelPosition;
61.         } else {
62.              //العجلة لا تلمس الأرض، علينا إذن أن نعيدها بشكل سلس إلى الموقع الأصلي
63.             Vector3 pos = transform.localPosition;
64.             pos = Vector3.Lerp(transform.localPosition, 
65.                                originalPos, Time.deltaTime);
66.             transform.localPosition = pos;
67.         }
68.     }
69. }

السرد 46: بريمج خاص بتحريك العجلات المرئية تبعا لحالة العجلات الفيزيائية

كل ما علينا فعله الآن هو إضافة هذا البريمج لكل واحدة من العجلات المرئية الأربع في السيارة، كما علينا أن نقوم بتحديد قيمة المتغير wheel من نافذة الخصائص بحيث ترتبط بأقرب عجلة فيزيائية لها. أخيرا علينا أن نحدد الكائن steeringAxis الذي سنستخدمه كمحور لدوران العجلات يمينا ويسارا، الأمر الذي ينطبق فقط على العجلات الأمامية. من أجل ذلك سنعمل على إضافة كائنين فارغين جديدين للسيارة هما SteeringAxis_R و SteeringAxis_L، والذين سنستخدمهما كمحورين لتدوير العجلتين الأماميتين نحو اليمين أو اليسار. بطبيعة الحال فإن الموقع الصحيح لكل واحد من هذين الكائنين هو النهاية اليمنى واليسرى لمحور السيارة الأمامي وهو الكائن المسمى FrontAxis.

بالعودة للبريمج نلاحظ وجود متغيرين آخرين هما lastSteerAngle و الذي سنخزن فيه زاوية التوجيه الأخيرة التي قمنا بقراءتها من العجلة الفيزيائية، بالإضافة للموقع originalPos وهو موقع العجلة المرئية الأصلي في الفضاء المحلي للسيارة. بحفظ هذا الموقع يمكننا أن نعيد العجلة لمكانها الصحيح حين لا يكون النابض منضغطا. بما أننا نتعامل هنا مع بريمج مهمته الأساسية هي تطبيق تأثيرات مرئية لحركات معينة، فإننا سنقوم بالتحديث باستخدام الدّالة ()LateUpdate والتي سبق وتعاملنا معها في حالة احتجنا لتأخير تحديث البريمج عن بقية البريمجات الأخرى. المهام التي نقوم بتنفيذها داخل هذه الدّالة هي أولا تدوير عجلات السيارة حول محاورها أثناء سير السيارة وبنفس السرعة، وهذه العملية تتم عبر تحويل سرعة العجلة الفيزيائية من دورة\دقيقة إلى درجة\ثانية، ومن ثم استخدام القيمة الناتجة كزاوية لتدوير العجلة حول محورها المحلي y وهو الأمر الذي يتم في السطر 31. السبب في تنفيذ الدوران حول المحور y في هذه الحالة هو أنّ العجلات ما هي إلا أسطوانات تم تدويرها بمقدار 90 درجة، بالتالي أصبح المحور y هو محور الدوران المناسب لتدوير العجلات.

بعد ذلك نتحقق من وجود محور لزاوية التوجيه وذلك بفحص المتغير steeringAxis، فإذا لم تكن قيمته null علينا حينها أن نقوم بتدوير العجلة حول هذا المحور بناء على زاوية التوجيه التي ستعطينا إياها العجلة الفيزيائية. تتم هذه العملية عبر خطوتين، حيث نقوم في الخطوة الأولى بإعادة العجلة إلى دورانها الأصلي وذلك بتدوير العجلة حول المحور المحلي y الخاص بـ steeringAxis. قيمة الدوران التي نحتاجها هنا هي القيمة السالبة لآخر تدوير تم في الإطار السابق أي بمقدار lastSteerAngle- ، بعدها يمكننا أن نقوم باعتماد زاوية التوجيه الجديدة وذلك بتدوير العجلة حول steeringAxis بمقدار يساوي wheel.steerAngle وهي زاوية التوجيه الخاصة بالعجلة الفيزيائية. أخيرا نقوم بتخزين الزاوية الجديدة في المتغير lastSteerAngle استعدادا لطرحها في الإطار المقبل لاستقبال زاوية جديدة. جدير بالذكر أنّ قيمة المتغير steeringAxis للعجلات الخلفية هي null، مما يجعل هذه الخطوات غير منطبقة عليها.

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

لتحريك العجلات بالشكل الصحيح علينا أولا أن نعرف ما إذا كانت العجلة تلمس سطح الأرض أم لا، ولهذا الغرض قمنا في السطر 51 بتعريف متغير من نوع WheelHit والذي يعطينا تفاصيل عن حالة العجلة الفيزيائية. بعدها نستدعي الدّالة ()GetGroundHit والتي تعطينا true في حال كانت العجلة تلمس سطح الأرض، مخزنة في الوقت ذاته تفاصيل هذا التلامس في المتغير hit والذي قمنا بتزويده مستخدمين الكلمة المفتاحية out. هذه الكلمة المفتاحية تعني ببساطة أنّ هذا المتغير تحديدا الهدف منه استخراج معلومات وقيم من الدّالة وليس تزويدها بقيم كما هي الحالة عادة في المتغيرات. أهم قيمة ستلزمنا هي hit.point والتي تمثل نقطة التماس بين العجلة وسطح الأرض. كيف سنستفيد من معرفة هذه النقطة في عملية تحريك العجلة المرئية؟ الجواب بسيط: لو قمنا بإضافة نصف قطر العجلة الفيزيائية لارتفاع هذه النقطة (أي hit.point.y) فسنحصل على ارتفاع مركز العجلة الفيزيائية عن سطح الأرض، وبالتالي نضبط ارتفاع العجلة المرئية ليصبح مساويا له كما في الأسطر 57 إلى 60. لتبسيط الأمور استخدمنا هنا إحداثيات فضاء المشهد فقط، مما ألغى أي دور لميلان السيارة عن سطح الأرض في اتجاه حركة العجلات المرئية، بيد أنّ الفرق – مع التجربة - لا يكاد يلاحظ.

قد يحدث في بعض الحالات أن "تقفز" السيارة، أي أن ترتفع عجلاتها – أو بعضها – عن سطح الأرض، مما يستوجب إعادة العجلة المرئية إلى موقعها الأصلي والذي سبق وحفظناه في المتغير originalPos. نستطيع معرفة كون العجلة مرتفعة عن سطح الأرض عن طريق الحصول على القيمة false عند استدعاء الدّالة ()GetGroundHit. ما ينبغي علينا القيام به في هذه الحالة هو إرجاع العجلة لموقعها الأصلي بحركة سلسة (أي ألا تقفز فجأة للموقع الأصلي). هذه الحالة قد تتكرر كثيرا، وأعني تنفيذ خطوة تحريك أو تدوير بمقدار معين لكن عبر عدة إطارات وبمقادير قليلة في كل مرة حتى تحصل على حركة سلسة للكائنات. الدّالة الرئيسية التي لها الدور الأكبر في عملية الانتقال السلس هي ()Lerp والتي يمكنك أن تجدها في عدّة أنواع من المتغيرات في Unity.

في حالة البريمج CarWheelAnimator فإننا نستدعي الدّالة ()Vector3.Lerp بهدف تحريك العجلة من موقعها الحالي إلى موقعها الأصلي بشكل سلس. هذه الدّالة تحتاج لثلاث متغيرات وهي بالترتيب: موقع المصدر وهو من نوع Vector3، وموقع الوجهة وهو أيضا من نوع Vector3، ونسبة تمثل "الاستيفاء" بين الموقعين وهي رقم بين صفر و واحد من نوع float. الاستيفاء نعني به هنا هو نسبة انحياز القيمة الناتجة لقيمة الوجهة. فلو كانت هذه القيمة صفرا، فإن ()Lerp ستعيد لنا نفس قيمة المصدر، ولو كانت واحد فستعيد لنا قيمة الوجهة. أمّا لو كانت نسبة الاستيفاء 0.5 مثلا، فإننا نحصل على قيمة المنتصف بين المصدر والوجهة. نستفيد من هذا السلوك في السطر 64 حيث نقوم بتغيير موقع العجلة بين موقعها الحالي transform.localPosition وموقعها الأصلي originalPos مستخدمين قيمة صغيرة جدا هي Time.deltaTime. وبما أنّ هذه القيمة صغيرة، ستكون القيمة الناتجة منحازة للمصدر بشكل أكبر، ولكنها تقترب شيئا فشيئا من الوجهة. بعد حساب هذه القيمة الناتجة نقوم بتخزينها في المتغير pos ومن ثم اعتمادها كموقع جديد للعجلة، وبذلك نكون اقتربنا ببطء من الوجهة، إلى أن نصل إليها بعد عدّة إطارات، وتحديدا بعد ثانية واحدة. الشكل 59 يمثل مواقع كل من العجلات الفيزيائية والمرئية، كما يمكنك مشاهدة النتيجة النهائية في المشهد scene16 في المشروع المرفق.

الشكل 59: نوابض العجلات في حالة الاسترخاء (يسارا) وفي حالة الانضغاط نحو الأسفل (يمينا). لاحظ أنّه في حال الانضغاط تنزل العجلات الفيزيائية تحت مستوى سطح الأرض وكذلك ينخفض مستوى جسم السيارة، بينما تبقى العجلات المرئية فوق سطح الأرض

الشكل 59: نوابض العجلات في حالة الاسترخاء (يسارا) وفي حالة الانضغاط نحو الأسفل (يمينا). لاحظ أنّه في حال الانضغاط تنزل العجلات الفيزيائية تحت مستوى سطح الأرض وكذلك ينخفض مستوى جسم السيارة، بينما تبقى العجلات المرئية فوق سطح الأرض

السابقالتالي

تعليقات واستفسارات