الفصل الثاني: شجرة اتخاذ القرارات وأولوياتها

العب

[wp_objects_pdf]

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

رسم بياني يمثل شجرة (يمينا) وآخر لا يمثل شجرة (يسارا)

رسم بياني يمثل شجرة (يمينا) وآخر لا يمثل شجرة (يسارا)

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

ستكون لعبتنا في هذا الفصل لعبة بطاقات بسيطة، وفيما يلي قواعد هذه اللعبة:

  • تتكون مجموعة ورق اللعب من 20 بطاقة، بحيث تكون 12 بطاقة منها للعلاج والثمانية الباقية للهجوم
  • تتألف اللعبة من خصمين يمتلك كل منهما مبدئيا صحة مقدارها 12
  • حين يأتي دور أي من الخصمين في اللعب فإن بإمكانه رمي إحدى البطاقات الثلاث التي يحملها، كما بإمكانه تمرير الدور لخصمه دون رمي أي بطاقة
  • إذا قام اللاعب برمي بطاقة علاج فإن صحته تزداد بمقدار قوة البطاقة، بينما تقوم أوراق الهجوم بتقليل صحة الخصم حين رميها. بعد رمي البطاقة فإن على الرامي سحب بطاقة عشوائية جديدة من أوراق اللعب المتبقية
  • قوة جميع البطاقات تساوي 1 مبدئيا، وتزداد قوة كل البطاقات التي مع اللاعبين بمقدار 1 بعد كل دور لعب، علما أن الحد الأقصى لقوة أي بطاقة هو 4
  • إذا قام اللاعب بتمرير الدور دون أن يرمي أي بطاقة، فإنه يحصل على ميزة وهي زيادة قوة بطاقاته فقط بعد ذلك الدور دون بطاقات خصمه
  • تنتهي اللعبة بخسارة أحد الطرفين والذي تصل صحته إلى صفر أولا، أو عندما يتم رمي جميع البطاقات حيث يكون الفائز هو اللاعب ذو الرصيد الأعلى

الشكل التالي يصور مشهدا من اللعبة التي سنقوم ببنائها في هذا الفصل.

مشهد من لعبة البطاقات الخاصة بهذا الفصل

مشهد من لعبة البطاقات الخاصة بهذا الفصل

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

لنقم أولا بإضافة كائن فارغ للمشهد ولنسمه sceneRoot. سنقوم بوضع هذا الكائن في نقطة الأصل وسنضيف الكاميرا كابن له. سنحتاج في هذا المثال لكاميرا ذات إسقاط عمودي، وسنقوم بتدويرها لتنظر نحو الأسفل، أي في الاتجاه السالب للمحور y وسنضعها في موقع فوق نقطة الأصل، وليكن مثلا النقطة (0 ,20 ,0). بالإضافة للكاميرا سنحتاج لكائنين فارغين مبدئيا هما CardsPlaceHolders و HUDTexts. هذه الأسماء تمثل الوظيفة التي سيؤديها كل من هذه الكائنات. عند تجهيز المشهد بشكل صحيح ستبدو هرميته كما في الشكل التالي.

هرمية المشهد المبدئية

هرمية المشهد المبدئية

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

لإضافة وسم جديد إلى Unity، اذهب للقائمة Edit > Project Settings > Tags and Layers، حيث ستجد مصفوفة باسم Tags. قم بإضافة عنصر جديد لهذه المصفوفة وقم بتسميته SceneRoot. بعدها قم باختيار الكائن الجذري من الهرمية ومن ثم اختر SceneRoot من القائمة في أعلى يسار شاشة الخصائص كما في الشكل أدناه

تحديد الوسم الخاص بكائن معين

تحديد الوسم الخاص بكائن معين

الخطوة الأولى لبناء لعبة البطاقات هي عمل البطاقات نفسها. لعمل أنواع مختلفة من البطاقات سنقوم بعمل قالب عام ومن ثم نقوم بتخصيص الكائنات بعد إنشائها من هذا القالب. يتكون كائن البطاقة من شكل مربع quad بالأبعاد (1 ,1 ,0.75). إضافة للمربع سنقوم بإضافة نص ثلاثي الأبعاد كابن له، وسيتولى هذا النص عرض نوع البطاقة ومقدار قوتها. الشكل التالي يوضح ما يجب أن يبدو عليه قالب البطاقات.

قالب بطاقات اللعب

قالب بطاقات اللعب

ابتداء من الإصدار 4.6، يحتوي Unity على نظام جديد لواجهة المستخدم، وبسبب ذلك لم يعد لكائن 3D Text وجود في قائمة الكائنات التي يمكن إضافتها للمشهد. الطريقة البديلة لإضافته هي إضافة كائن فارغ ومن ثم إضافة المكوّن TextMesh إليه.

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

1. using UnityEngine;
1. using UnityEngine;
2. using System.Collections;
3. 
4. public class PlayCard : MonoBehaviour {
5.  
6.  //أنواع البطاقات المتوفرة
7.  public const int HEAL = 0;
8.  public const int ATTACK = 1;
9.  
10.     //نوع هذه البطاقة
11.     public int cardType = HEAL;
12.     
13.     //هل اللاعب هو من يحمل هذه البطاقة؟
14.     public bool isPlayerCard = false;
15.     
16.     //عندما يتم رمي البطاقة فإنها
17.     //ستتحرك باتجاه هذا الموقع
18.     public Vector3 throwPosition;
19.     
20.     //مقدار قوة البطاقة والذي
21.     //يزداد بعد كل دور لعب
22.     int power = 1;
23.     
24.     //الكائن الذي يعرض النص المكتوب على البطاقة
25.     TextMesh cardText;
26.     
27.     //يحدد هذا المتغير إذا ما كانت البطاقة قد تم رميها
28.     bool cardThrown = false;
29.     
30.     //سرعة التحريك حين رمي البطاقة
31.     float throwSpeed = 5;
32.     
33.     
34.     void Start () {
35.         cardText = GetComponentInChildren<TextMesh>();
36.     }
37.     
38.     
39.     void Update () {
40.         //قم بتحديث الشكل المرئي للبطاقة
41.         UpdateAppearance();
42.         
43.         //إن كانت البطاقة قد تم رميها فقم بتحديث التحريك
44.         if(cardThrown){
45.             UpdateAnimation();
46.         }
47.     }
48.     
49.     //قم بتحديث الشكل المرئي للبطاقة
50.     void UpdateAppearance(){
51.         //البطاقات التي يحملها اللاعب
52.         //أو تلك التي تم رميها
53.         //هي التي يجب أن تكون ظاهرة للاعب
54.         if(isPlayerCard || cardThrown){
55.             //تحديد اللون والنص بناء على نوع البطاقة
56.             if(cardType == HEAL){
57.                 cardText.text = "H" + power;
58.                 renderer.material.color = Color.green;
59.             } else {
60.                 cardText.text = "A" + power;
61.                 renderer.material.color = Color.red;
62.             }
63.         } else {
64.             //اعرض علامة استفهام
65.             cardText.text = "?";
66.             renderer.material.color = Color.grey;
67.         }
68.     }
69.     
70.     //تستدعى هذه الدّالة عند رمي البطاقة
71.     public void ThrowCard(){
72.         cardThrown = true;
73.         //الفصل عن الموقع الحالي
74.         GameObject sceneRoot = GameObject.FindWithTag("SceneRoot");
75.         transform.parent = sceneRoot.transform;
76.         
77.         //قم بإبلاغ البريمجات الأخرى عن رمي البطاقة
78.         SendMessageUpwards("OnCardThrowBegin", 
79.                     SendMessageOptions.DontRequireReceiver);
80.     }
81.     
82.     //تقوم بزيادة قوة البطاقة بمقدار 1
83.     //والحد الأقصى هو 4
84.     public void IncrementPower(){
85.         //قوة البطاقات التي تم رميها
86.         //لا يمكن أن تزيد
87.         if(!cardThrown && power < 4){
88.             power++;
89.         }
90.     }
91.     
92.     //تقوم بإرجاع قوة البطاقة
93.     public int GetPower(){
94.         return power;
95.     }
96.     
97.     //تقوم بتحريك البطاقة بعد رميها
98.     void UpdateAnimation(){
99.         
100.        if(transform.position == throwPosition){
101.            //وصلت البطاقة لموقع الرمي المطلوب، لا شيء آخر يمكن فعله
102.            return;
103.        }
104.        
105.        //حرك البطاقة بشكل سلس نحو موقع الرمي
106.        transform.position = Vector3.Lerp(transform.position, 
107.                        throwPosition, Time.deltaTime * throwSpeed);
108.        
109.        //احسب المسافة بين البطاقة وموقع الرمي
110.        float dist = Vector3.Distance(transform.position, throwPosition);
111.        
112.        //المنطقة الميتة بين الموقعين هي 1 سم
113.        if(dist < 0.1f){
114.            transform.position = throwPosition;
115.            //قم بإرسال رسالة للكائن الجذري
116.            //تحتوي على البطاقة المرمية
117.            SendMessageUpwards("OnCardThrowComplete", this,
118.                    SendMessageOptions.DontRequireReceiver);
119.            //قم بتدمير الكائن بعد ثانية
120.            Destroy(gameObject, 1f);
121.        }
122.    }
123. }

بريمج بطاقة اللعب

المتغيرات الثلاث HEAL و ATTACK و cardType تساعدنا في تحديد نوع البطاقة. يمكنك أن تلاحظ أن كلا من HEAL و ATTACK قد تم تعريفهما باستخدام كلمة const والتي تعني أن قيمتي المتغيرين ثابتتان ولا يمكن تغييرهما. استخدام المتغيرات ثابتة القيمة يجعل من السهل الرجوع لمعنى المتغير وهو أفصح في التعبير من الرقم المجرد. ففي حالتنا هذه على سبيل المثال فإن قيمة المتغير cardType قد تكون 0 وتعني أن البطاقة هي بطاقة صحة أو 1 والتي تعني بطاقة هجوم. بدلا من استخدام الرقمين 0 و 1 بشكل صريح فإننا استبدلناهما بمتغيرين أكثر وضوحا وأسهل للتذكر وهما HEAL و ATTACK. المتغيرات المعرفة بكلمة const لها خاصية مميزة أخرى وهي إمكانية الوصول إليها مباشرة من أي بريمج آخر وذلك باستخدام اسم البريمج الذي تم تعريفها فيه. فمثلا يمكن لجميع البرمجات الأخرى أن تصل لقيمة هذين المتغيرين باستخدام التعبير PlayCard.HEAL أو PlayCard.ATTACK. هذه الخاصية تحديدا ستكون ذات استخدام موسع عبر هذا الفصل.

المتغير isPlayerCard يخبرنا إن كان اللاعب هو من يحمل هذه البطاقة، بينما يحدد المتغير power مقدار قوة البطاقة. المتغير throwPosition يستخدم لغرض تحريك البطاقة عند رميها، حيث تكون أن لكل من اللاعب البشري والذكاء الاصطناعي أماكن رمي مختلفة لبطاقاتهما منعا لتكدس البطاقات فوق بعضها وبالتالي تسهيل رؤيتها. إضافة لذلك فإن المتغيرين cardThrown و throwSpeed يستخدمان لتحريك البطاقة حين رميها. فعندما يتم رمي البطاقة يجب أن تتحرك بشكل سلس من موقعها في يد حاملها إلى موقع الرمي الخاص بها. أخيرا فإن المتغير cardText يوفر مرجعا للوصول لكائن 3D Text الموجود كابن لكائن البطاقة.

الشيء الوحيد الذي يلزمنا في البداية عند تنفيذ ()Start هو إيجاد مكوّن TextMesh الموجود في الكائن الابن 3D Text وتخزينه في المتغير cardText. خلال التحديث أي في الدّالة ()Update ينبغي تحديث مظهر البطاقة المرئي وذلك عن طريق استدعاء ()UpdateAppearance. هذه الدّالة المعرفة في الأسطر 50 إلى 68 تقوم بتغيير لون البطاقة إضافة للنص الذي يظهر عليها. بما أن هذا الشكل المرئي ظاهر للاعب البشري فإن البطاقات التي يحملها لاعب الذكاء الاصطناعي يجب ألا تكون ظاهرة إلى أن يتم رميها. لهذا نقوم بفحص كون البطاقة مملوكة للاعب أو أنها بطاقة تم رميها بغض النظر عن مالكها. في حال تأكدنا من أن البطاقة يجب أن تظهر للاعب فإننا نقوم بضبط لونها للأخضر إن كانت بطاقة صحة أو للأحمر إن كانت بطاقة هجوم. علاو على ذلك فإننا نقوم بتغيير النص الظاهر عليها إلى الحرف A إن كانت بطاقة هجوم أو H إن كانت بطاقة صحة، وبعد هذا الحرف نضيف رقما مساويا لمقدار قوة البطاقة. إن كانت البطاقة غير مملوكة للاعب لم يتم رميها فيجب إخفاؤها عنه، وفي هذه الحالة نغير لونها للرمادي والنص الظاهر عليها لعلامة استفهام "؟".

الأمر الآخر الذي تقوم الدّالة ()Update بفحصه هو استدعاء ()UpdateAnimation من أجل تحريك البطاقة في حال كانت قد تم رميها. قبل الخوض في تفاصيل هذه الدّالة لنتعرف أولا على كيفية رمي البطاقة وما الذي يحدث حين رميها.

عملية رمي البطاقة تبدأ باستدعاء الدّالة ()ThrowCard وهي معرفة في الأسطر 71 إلى 80. هذه الدّالة تقوم بتغيير قيمة cardThrown إلى true ومن ثم تقوم بفصل البطاقة عن كائن الموقع الحالي. كائنات المواقع هي عبارة عن كائنات فارغة تمثل الأماكن التي توضع فيها البطاقات التي يحملها كل لاعب بيده، وسنتحدث عنها لاحقا. كما سبق وذكرنا؛ فإن جميع كائنات المشهد يجب أن تكون أبناء للكائن الجذري في المشهد. من أجل ذلك علينا بعد فصل البطاقة عن كائن الموقع علينا أن نجعلها ابنا مباشرا للكائن الجذري. لإيجاد هذا الكائن استخدمنا ("GameObject.FindWithTag(“SceneRoot. هذه الدّالة تقوم بالبحث في المشهد عن أي كائن يحمل الوسم الذي نعطيه لها وهو في هذه الحالة SceneRoot. حالما يتم إيجاد الكائن الذي يحمل هذا الوسم وهو الكائن الجذري بطبيعة الحال فإننا نقوم بتحديده كأب للبطاقة التي تم رميها وذلك في السطرين 74 و 75. أخيرا فإن علينا إعلام البريمجات الأخرى بأن عملية رمي البطاقة قد بدأت وذلك عن طريق إرسال الرسالة OnCardThrowBegin. لاحظ أننا هذه المر استخدمنا الدّالة ()SendMessageUpwards والتي يدل اسمها على أن الرسالة تتجاوز حدود الكائن الحالي إلى المستويات الأعلى في الهرمية وهي آباء الكائن المباشرين وغير المباشرين (أي الآباء والأجداد إن صح التعبير).

بمجرد أن تتغير قيمة cardThrown إلى true فإن ذلك يؤدي إلى البدء في استدعاء الدّالة ()UpdateAnimation في كل دورة تحديث للمشهد، وهذه الدّالة معرفة قي الأسطر 98 إلى 123. وظيفة هذه الدّالة هي تحريك البطاقة بشكل سلس من موقعها الحالي إلى موقع الرمي، وعندما تصل إلى ذلك الموقع فإنها تبقى هناك لفترة قصيرة قبل أن يتم تدميرها. من أجل ذلك علينا أن نقوم بفحص موقع البطاقة في كل مرة والتأكد من كونها قد وصلت لموقع الرمي أم لا. إن لم تكن قد وصلت بعد فإننا نقوم بتحريكها مستخدمين ()Vector3.Lerp. فإذا قلت المسافة بين البطاقة وموقع الرمي عن سنتيمتر واحد فإننا نقوم بوضعها مباشرة في موقع الرمي ونرسل الرسالة OnCardThrowComplete للمستويات الأعلى في الهرمية. وأخيرا نقوم بتدمير البطاقة بعد ثانية واحدة عن طريق الدّالة ()Destroy.

الدّالتان الأخيرتان اللتان سنناقشهما هنا هما ()GetPower و ()IncrementPower. هاتان الدّالتان بسيطتان جدا ويستخدمان لقراءة قيمة القوة الحالية للبطاقة إضافة لزيادتها. لاحظ أن الدّالة ()IncrementPower تقوم بتطبيق قانون الحد الأعلى للقوة وهو 4 وتمنع زيادته عن ذلك، إضافة لأنها تمنع زيادة الطاقة للبطاقات التي تم رميها، لأن قاعدة زيادة القوة تنطبق على البطاقات التي يحملها اللاعبون في أيديهم فقط.

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


1. using UnityEngine;
2. using System.Collections;
3. 
4. //هذا البريمج يحدد موقعا على الشاشة
5. //توضع عليه الأوراق التي في أيدي اللاعبين
6. public class CardPlaceholder : MonoBehaviour {
7.  
8.  //هل هذا الموقع يخص اللاعب البشري؟
9.  public bool forPlayer = false;
10.     
11.     
12.     void Start () {
13.     
14.     }
15.     
16.     
17.     void Update () {
18.     
19.     }
20.     
21.     //هل هذا المكان فارغ ومتاح لوضع بطاقة؟
22.     public bool IsFree(){
23.         //إن كان الموقع عليه بطاقة
24.         //فإنها ستكون مضافة كابن له
25.         //أي عدم وجود أطفال يعني أنه فارغ
26.         return transform.childCount == 0;
27.     }
28.     
29.     //تقوم بوضع البطاقة المزودة على الموقع
30.     public void PutCard(PlayCard card){
31.         card.transform.parent = transform;
32.         card.transform.localPosition = Vector3.zero;
33.         if(forPlayer){
34.             card.isPlayerCard = true;
35.         }
36.     }
37.     
38.     //تقوم بإرجاع البطاقة الموضوعة إن وجدت
39.     public PlayCard GetCard(){
40.         if(IsFree()){
41.             return null;
42.         }
43.         
44.         return transform.GetChild(0).GetComponent<PlayCard>();
45.     }
46. }

البريمج الخاص بكائنات مواقع البطاقات

علينا الآن الانتقال للبريمج الرئيسي للعبة وهو المسؤول عن إدارة حالة اللعبة وتوزيع الأوراق والأدوار بين اللاعبين، إضافة إلى تطبيق قواعد اللعبة. هذا البريمج هو CardGameManger وهو موضح في السرد التالي.


1. using UnityEngine;
2. using System.Collections.Generic;
3. 
4. //يقوم هذا البريمج بتطبيق قواعد اللعبة
5. public class CardGameManager : MonoBehaviour {
6.  
7.  //القالب المستخدم لبناء البطاقات
8.  public GameObject cardPrefab;
9.  
10.     //عدد بطاقات الهجوم المتوفرة في المجموعة
11.     public int attackCards = 12;
12.     
13.     //عدد بطاقات الهجوم المتبقية في هذه الجولة
14.     int remainingAttacks;
15.     
16.     //عدد بطاقات العلاج المتوفرة في المجموعة
17.     public int healCards = 8;
18.     
19.     //عد بطاقات العلاج المتبقية في هذه الجولة؟
20.     int remainingHeals;
21.     
22.     //تحدد صاحب الدور الحالي في اللعب
23.     bool playerTurn = true;
24.     
25.     //مقدار الصحة المتبقي لكل طرف
26.     int playerHealth, cpuHealth;
27.     
28.     //خلال وضع الانتظار أي أثناء التحريك
29.     //لن يسمح لأي من اللاعبين باللعب
30.     bool waitMode = false;
31.     
32.     bool gameEnded = false;
33.     
34.     //مصفوفة تحتوي على مواقع البطاقات في المشهد
35.     CardPlaceholder[] cardHolders;
36.     
37.     
38.     void Start () {
39.         //للكائن الجذري SceneRoot قم بإعطاء السمة
40.         gameObject.tag = "SceneRoot";
41.         FindPlaceholders();
42.         StartGame();
43.     }
44.     
45.     
46.     void Update () {
47.         if(gameEnded) {
48.             //انتهت اللعبة ويمكن اللعب مجددا
49.             //بالضغط على مفتاح المسافة
50.             if(Input.GetKeyDown(KeyCode.Space)){
51.                 StartGame();
52.             }
53.         }
54.     }
55.     
56.     void StartGame(){
57.         gameEnded = false;
58.         playerTurn = true;
59.         waitMode = false;
60.         playerHealth = cpuHealth = 12;
61.         
62.         remainingHeals = healCards;
63.         remainingAttacks = attackCards;
64.         
65.         //أزل أي بطاقات موجودة
66.         //في المشهد من الجولة 
67.         //السابقة
68.         foreach(PlayCard card in FindObjectsOfType<PlayCard>()){
69.             Destroy(card.gameObject);
70.         }
71.         
72.         DistributeCards();
73.         //أرسل رسالة تخبر ببدء اللعبة
74.         SendMessage("GameStarted", SendMessageOptions.DontRequireReceiver);
75.     }
76.     
77.     //تقوم بإيجاد كائنات المواقع في
78.     //المشهد وتضيفها لمصفوفة المواقع
79.     void FindPlaceholders(){
80.         cardHolders = FindObjectsOfType<CardPlaceholder>();
81.     }
82.     
83.     //تقوم بإرجاع العدد الكلي للبطاقات المتبقية
84.     public int RemainingCardsCount(){
85.         return remainingHeals + remainingAttacks;
86.     }
87.     
88.     
89.     //تقوم بإرجاع قيمة صحة اللاعب
90.     public int GetPlayerHealth(){
91.         return playerHealth;
92.     }
93.     
94.     //تقوم بإرجاع قيمة صحة الذكاء الاصطناعي
95.     public int GetCpuHealth(){
96.         return cpuHealth;
97.     }
98.     
99.     //تقوم بتوزيع البطاقات الأولية بشكل
100.    //عشوائي على جميع كائنات المواقع
101.    void DistributeCards(){
102.        for(int i = 0; i < cardHolders.Length; i++){
103.            PlayCard card = DrawNewCard();
104.            
105.            if(card == null){
106.                Debug.LogWarning("No enough cards for initial distribution");
107.                return;
108.            }
109.            
110.            cardHolders[i].PutCard(card);
111.            
112.            //يتم تحديد موقع الرمي حسب مالك البطاقة
113.            if(card.isPlayerCard){
114.                card.throwPosition = Vector3.right * 0.6f;
115.            } else {
116.                card.throwPosition = Vector3.left * 0.6f;
117.            }
118.        }
119.    }
120.    
121.    //تقوم بسحب بطاقة عشوائية جديدة من المجموعة
122.    PlayCard DrawNewCard(){
123.        bool canGiveHeal = remainingHeals > 0;
124.        bool canGiveAttack = remainingAttacks > 0;
125.        
126.        if(!canGiveHeal && !canGiveAttack){
127.            //لم يعد هناك أية بطاقات
128.            return null;
129.        }
130.        
131.        GameObject newCard = (GameObject)Instantiate(cardPrefab);
132.        
133.        PlayCard card = newCard.GetComponent<PlayCard>();
134.        
135.        if(canGiveHeal && canGiveAttack){
136.            //النوعان متوفران
137.            int rand = Random.Range(0, 2);
138.            
139.            if(rand == 0){
140.                //الرقم العشوائي أفرز نوع العلاج
141.                remainingHeals--;
142.                card.cardType = PlayCard.HEAL;
143.            } else {
144.                //الرقم العشوائي أفرز نوع الهجوم
145.                remainingAttacks--;
146.                card.cardType = PlayCard.ATTACK;
147.            }
148.        } else if(canGiveHeal){
149.            //لم يعد هناك سوى بطاقات العلاج
150.            remainingHeals--;
151.            card.cardType = PlayCard.HEAL;
152.        } else if(canGiveAttack){
153.            //لم يعد هناك سوى بطاقات الهجوم
154.            remainingAttacks--;
155.            card.cardType = PlayCard.ATTACK;
156.        }
157.        
158.        return card;
159.    }
160.    
161.    //هل يمكن للاعب أن يلعب
162.    public bool CanPlayerPlay(){
163.        return !gameEnded && playerTurn && !waitMode;
164.    }
165.    
166.    //هل يمكن للذكاء الاصطناعي أن يلعب
167.    public bool CanCpuPlay(){
168.        return !gameEnded && !playerTurn && !waitMode;
169.    }
170.    
171.    //يتم استدعاء الدّالة عند قيام أحد اللاعبين بتمرير الدور
172.    public void PerformPass(bool isPlayer){
173.        if(isPlayer != playerTurn){
174.            //هذا ليس دور اللاعب الذي استدعى لا يمكن فعل شيء
175.            return;
176.        }
177.        
178.        //قم بتبديل الدور
179.        playerTurn = !playerTurn;
180.        
181.        //قم بزيادة قوة بطاقات اللاعب الذي 
182.        //مرر الدور فقط
183.        foreach(CardPlaceholder holder in cardHolders){
184.            if(!holder.IsFree() && holder.forPlayer == isPlayer){
185.                holder.GetCard().IncrementPower();
186.            }
187.        }
188.        
189.        //قم بإعلام البريمجات بحدوث التمرير
190.        SendMessage("PassPerformed", isPlayer);
191.        
192.        //قم بالدخول في وضع الانتظار لمدة ثانية
193.        EnterWaitMode();
194.        Invoke("ExitWaitMode", 1.0f);
195.    }
196.    
197.    //تخبر إذا ما انتهت اللعبة أم لا
198.    public bool GameEnded(){
199.        return gameEnded;
200.    }
201.    
202.    //تخبر إذا ما كان دور اللاعب أم لا
203.    public bool IsPlayerTurn(){
204.        return playerTurn;
205.    }
206.    
207.    //تقوم بإرجاع قائمة بالبطاقات المملوكة للذكاء الاصطناعي
208.    public List GetCpuCards(){
209.        List cpuCards = new List();
210.        //قم بإضافة كافة البطاقات الموجودة
211.        //في المواقع غير المملوكة للاعب
212.        foreach(CardPlaceholder holder in cardHolders){
213.            if(!holder.IsFree() && !holder.forPlayer){
214.                cpuCards.Add(holder.GetCard());
215.            }
216.        }
217.        
218.        return cpuCards;
219.    }
220.    
221.    void OnCardThrowBegin(){
222.        EnterWaitMode();
223.    }
224.    
225.    //تقوم بالتعامل مع البطاقات التي تم رميها
226.    void OnCardThrowComplete(PlayCard card){
227.        //أولا يتم تفعيل تأثير البطاقة حسب نوعها ومالكها وقوتها
228.        if(card.isPlayerCard){
229.            if(card.cardType == PlayCard.HEAL){
230.                playerHealth += card.GetPower();
231.            } else if(card.cardType == PlayCard.ATTACK){
232.                cpuHealth -= card.GetPower();
233.                
234.                //قم بإنهاء اللعبة إذا وصلت صحة الذكاء الاصطناعي لصفر
235.                if(cpuHealth <= 0){
236.                    cpuHealth = 0;
237.                    EndGame();
238.                    return;
239.                }
240.            }
241.        } else {
242.            if(card.cardType == PlayCard.HEAL){
243.                cpuHealth += card.GetPower();
244.            } else if(card.cardType == PlayCard.ATTACK){
245.                playerHealth -= card.GetPower();
246.                
247.                //قم بإنهاء اللعبة إذا وصلت صحة اللاعب لصفر
248.                if(playerHealth <= 0){
249.                    playerHealth = 0;
250.                    EndGame();
251.                    return;
252.                }
253.            }
254.        }
255.        
256.        //قم بزيادة قوة جميع البطاقات
257.        foreach(CardPlaceholder holder in cardHolders){
258.            if(!holder.IsFree()){
259.                holder.GetCard().IncrementPower();
260.            }
261.        }
262.        
263.        //قم بإعطاء بطاقة جديدة عشوائية
264.        //لمن رمى البطاقة الحالية
265.        PlayCard newCard = DrawNewCard();
266.        if(newCard != null){
267.            newCard.isPlayerCard = card.isPlayerCard;
268.            
269.            //ضع البطاقة في أول موقع فارغ تجده لهذا اللاعب
270.            foreach(CardPlaceholder holder in cardHolders){
271.                if(holder.IsFree() &&
272.                    holder.forPlayer == newCard.isPlayerCard){
273.                    holder.PutCard(newCard);
274.                    
275.                    //قم بتحديد مكان الرمي تبعا للمالك
276.                    if(newCard.isPlayerCard){
277.                        //بطاقات اللاعب تُرمى يمينا
278.                        newCard.throwPosition = Vector3.right * 0.6f;
279.                    } else {
280.                        //بطاقات الذكاء الاصطناعي تُرمى يسارا
281.                        newCard.throwPosition = Vector3.left * 0.6f;
282.                    }
283.                    
284.                    break;
285.                }
286.            }
287.        }
288.        
289.        //قم بالتحقق من أن جميع البطاقات قد رُميت
290.        if(AllCardsThrown()){
291.            //في هذه الحالة يتم إنهاء اللعبة
292.            EndGame();
293.        } else {
294.            //قم بتبديل الدور
295.            playerTurn = !playerTurn;
296.            //قم بإعلام البريمجات الأخرى بانتهاء
297.            //الدور مع إرفاق البطاقة التي تم رميها
298.            SendMessage("TurnEnded", card);
299.            //قم بالخروج من وضع الانتظار لتسمح للاعب التالي باللعب
300.            ExitWaitMode();
301.        }
302.        
303.    }
304.    
305.    //تقوم بإنهاء اللعبة وتحديد الفائز
306.    void EndGame(){
307.        //الفائز هو الطرف صاحب
308.        //الصحة الأعلى
309.        gameEnded = true;
310.        int result = 0;//تعادل
311.        if(playerHealth > cpuHealth){
312.            result = 1;//فوز اللاعب
313.        } else if(playerHealth < cpuHealth){
314.            result = -1;//فوز الذكاء الاصطناعي
315.        }
316.        
317.        //قم بإعلام البريمجات الأخرى بانتهاء اللعبة
318.        SendMessage("GameEnded", result);
319.    }
320.    
321.    //تقفحص إذا ما تم رمي جميع البطاقات
322.    bool AllCardsThrown(){
323.        //يجب أن تكون جميع المواقع فارغة
324.        foreach(CardPlaceholder holder in cardHolders){
325.            if(!holder.IsFree()){
326.                return false;
327.            }
328.        }
329.        
330.        return true;
331.    }
332.    
333.    //تقوم بتفعيل طور الانتظار لأجل تحريك البطاقات
334.    void EnterWaitMode(){
335.        waitMode = true;
336.    }
337.    
338.    //تقوم بالخروج من طور الانتظار حين انتهاء تحريك البطاقات
339.    void ExitWaitMode(){
340.        waitMode = false;
341.    }
342. }

البريمج الرئيسي للعبة

ليس هنالك ما يخيف في هذا البريمج الطويل حتى وإن تجاوزت سطوره 340 سطرا. حيث أنه طويل فقط لأنه يقوم بالكثير من المهام وليس لأنه يقوم بأي مهام معقدة. بشكل عام فإن هذا البريمج يعمل على تطبيق قواعد اللعبة التي سبق وحددناها في هذا الفصل. كالعادة يبدأ البريمج بتعريف المتغيرات اللازمة لعمله. هذا البريمج يجب أن يضاف على الكائن الجذري للمشهد وسيقوم بمهمة توزيع البطاقات الأولية على اللاعبين بشكل عشوائي. لذا فإن المتغير الأول هو cardPrefab والذي سيمثل مرجعا لقالب البطاقة الذي سبق وأنشأناه. المتغيرات الأربعة attackCards و remainingAttackCards و healCards و remainingHealCards تمثل مجموعة أوراق اللعب وتغيراتها، من حيث العدد الكلي للأوراق من كل نوع والعدد المتبقي خلال اللعب. لاحظ أن المتغيرين attackCards و healCards معرفان باستخدام public مما يعني أه يمكنك تغيير قيمهما من نافذة الخصائص للحصول على أنماط لعب جديدة.

المتغيرات playerTurn و playerHealth و cpuHealth تقوم بتتبع قيم مهمة خاصة بحالة اللعبة، وهي على الترتيب صاحب الدور في اللعب ومقدار صحة اللاعب ومقدار صحة الذكاء الاصطناعي. فإذا كانت قيمة playerTurn مثلا هي false، حينها يجب منع اللاعب من رمي البطاقات لأنه دور الذكاء الاصطناعي في اللعب. لدينا أيضا المتغير waitMode والذي يحدد ما إذا كانت اللعبة في طور الانتظار بعد رمي البطاقة إلى أن تصل موقع الرمي، والمتغير gameEnded والذي يخبرنا إذا ما كانت اللعبة قد انتهت أم لا. في حالة وقت الانتظار فإن كلا اللاعبين سيكونان ممنوعان من رمي أية بطاقات. أخيرا لدينا المصفوفة []cardHolders والتي تخزن جميع كائنات مواقع البطاقات الموجودة في المشهد.

يبدأ هذا البريمج كالعادة مع الدّالة ()Start حيث يقوم بتغيير الوسم الخاص بالكائن إلى SceneRoot. صحيح أننا قد نفذنا هذا الأمر مسبقا عن طريق نافذة الخصائص، إلا أنه من الأفضل دائما ضبط أكبر قدر ممكن من الإعدادات من داخل البريمجات لتقليل مصادر الخطأ المحتملة للحد الأدنى. بعد ذلك يتم إيجاد جميع كائنات المواقع وإضافتها للمصفوفة []cardHolders، وذلك عبر استدعاء الدّالة ()FindPlaceholders المعرفة في الأسطر 79 إلى 81. بعد ذلك يتم استدعاء الدّالة ()StartGame وهي المسؤولة عن ضبط قيم المتغيرات الأولية من أجل البدء في جولة لعب جديدة.

الدّالة ()StartGame والمعرفة في الأسطر 56 إلى 75 تبدأ بضبط قيم كل من gameEnded و waitMode إلى false إضافة إلى ضبط playerTurn إلى true وذلك لتسمح للاعب بالبدء أولا. يتم أيضا ضبط قيم playerHealth و cpuHealth إلى 12 كما يتم ضبط متغيرات البطاقات المتبقية remainingHeals و remainingAttacks لتصبح مساوية لقيمة عدد البطاقات الكلي في مجموعة ورق اللعب. بما أنه من الممكن أن يلعب اللاعب أكثر من جولة، يجب على هذه الدّالة أن تتأكد من خلو المشهد من أية بطاقات متبقية من الجولة السابقة. ولتحقيق هذه الغاية تقوم الدّالة بالبحث عن جميع الكائنات الموجودة من نوع PlayCard وتقوم بالمرور عليها جميعا واستدعاء ()Destroy لتدميرها وحذفها من المشهد. بعد أن يصبح المشهد نظيفا يمكن البدء بتوزيع الأوراق الأولية عشوائيا على اللاعبين، وذلك عن طريق استدعاء ()DestributeCards حيث تقوم هذه الدّالة بإعطاء كل لاعب 3 بطاقات عشوائية من مجموعة الأوراق. أخيرا يتم إرسال الرسالة GameStarted لإعلام كافّة البريمجات الأخرى بأنّ اللعبة قد بدأت.

الدّالة التالية التي سنناقشها هي ()DistributeCards والواقعة بين السطرين 101 و 119. تقوم هذه الدّالة بالمرور على عناصر المصفوفة []cardHolders حيث قمنا سلفا بتخزين جميع كائنات المواقع، ومن ثم تقوم باستدعاء دالّة أخرى هي ()DrawNewCard من أجل سحب بطاقة عشوائية جديدة من البطاقات المتبقية في المجموعة (سنتحدث عن هذه الدّالة بعد قليل). إن لم تتمكن ()DrawNewCard من إيجاد بطاقات متبقية في مجموعة اللعب، فإنها تقوم في هذه الحالة بإرجاع null حيث يجب على الدّالة ()DistributeCards في هذه الحالة أن تطبع رسالة تحذير لعدم كفاية بطاقات مجموعة اللعب للتوزيع الأولي. في حال تم إرجاع بطاقة جديدة من ()DrawNewCard وهو الأمر الطبيعي فإن هذه البطاقة يجب أن توضع على كائن الموقع وذلك عن طريق إعطائها للدّالة ()Put في الموقع الحالي. ما تقوم به ()Put هو تعديل قيمة المتغير isPlayerCard في بريمج البطاقة PlayCard وذلك حتى يصبح مساويا للمتغير forPlayer في بريمج الموقع CardPlaceholder. وهذا يضمن لنا أن قيمة isPlayerCard ستكون صحيحة بمجرد وضع البطاقة في مكان موقع معين بغض النظر عن كون الموقع للاعب أم للذكاء الاصطناعي. بناء على هذه القيمة أيضا، يتم في الأسطر 113 إلى 117 تحديد موقع الرمي للبطاقة حيث تتحرك بطاقات اللاعب للجهة اليمنى حين رميها بينما تتحرك بطاقات الذكاء الاصطناعي للجهة اليسرى.

كما سبق وذكرنا فإن الدّالة ()DrawNewCard تقوم بسحب بطاقة عشوائية من تلك البطاقة المتبقية في المجموعة. هذه الدّالة المعرفة في الأسطر 122 إلى 159 تبدأ بتحديد أنواع البطاقات التي يمكن سحبها وذلك عبر فحص قيم remainingHeals و remainingAttacks. نتيجة لذلك يتم ضبط قيمتي المتغيرين canGiveHeal و canGiveAttack واللذان يحددان إمكانية وجود كل نوع من أنواع البطاقات في المجموعة، فإذا كانت قيمتا هذين المتغيرين false فهذا يعني عدم وجود أي بطاقات يمكن سحبها وبالتالي تقوم الدّالة بإرجاع القيمة null. ما عدا ذلك فلدينا ثلاث احتمالات: أولها أن البطاقات المتبقية مختلطة وتحتوي كلا النوعين، وثانيها أن جميع البطاقات المتبقية هي بطاقات علاج، وثالثها أن جميع البطاقات المتبقية هي بطاقات هجوم. في الحالة الأولى تقوم الدّالة بتوليد رقم عشوائي بين 0 و 1 (تذكر أن الحد الأعلى غير مشمول بالتالي فإن العدد 2 لا يمكن أن يتم توليده هنا). فإذا كان الرقم الناتج صفرا فإن البطاقة الجديدة ستكون بطاقة علاج، وبخلاف ذلك تكون بطاقة هجوم. في الحالتين الثانية والثالثة من الواضح أنه لا حاجة لتوليد أية أرقام عشوائية حيث أن الخيارات واضحة. أخيرا يجب الانتباه أنه في كل الحالات يجب تقليل عدد البطاقات المتبقية من النوع الذي تم سحب بطاقة منه، وذلك قبل إرجاع البطاقة الجديدة.

بعد الانتهاء من توزيع البطاقات يبقى بريمج CardGameManager خاملا في انتظار صدور أفعال من اللاعبين، سواء كانت هذه الأفعال رمي بطاقات أو تمرير أدوار. عندما يرمي أحد اللاعبين بطاقة فإنه - وكما رأينا سابقا - يتم إرسال الرسالة OnCardThrowBegin. هذه الرسالة يستقبلها CardGameManager عن طريق الدّالة ()OnCardThrowBegin والمعرفة في الأسطر 221 إلى 223. عندما يرمي أحد اللاعبين بطاقة يجب أن تدخل اللعبة في طور الانتظار إلى أن يتم تحريك البطاقة إلى موقع الرمي الخاص بها. من أجل ذلك يتم استدعاء الدّالة ()EnterWaitMode والمعرفة في الأسطر 334 إلى 336. هذه الدّالة تقوم ببساطة بتغيير قيمة waitMode إلى true.

بمجرد وصول البطاقة التي تم رميها إلى موقع الرمي فإنها تقوم بإرسال الرسالة OnCardThrowComplete والتي يستقبلها CardGameManager مستخدما الدّالة ()OnCardThrowComplete في الأسطر 226 إلى 303. حين وصول البطاقة لموقع رميها فإننا نتوقع أن نرى تأثيرها على حالة اللعبة. وهذا التأثير هو زيادة صحة الرامي إن كانت بطاقة علاج أو تقليل صحة خصمه إن كانت بطاقة هجوم. بعد إحداث هذا التأثير يجب التأكد مما إذا كانت اللعبة قد انتهت أم لا، إضافة إلى سحب بطاقة جديدة للاعب الذي قام برمي البطاقة الأخيرة. الخطوة الأولى وهي تأثير البطاقة على صحة أحد اللاعبين تتم في الأسطر 227 إلى 254، حيث تلاحظ أنه في حال كانت بطاقة هجوم يتم فحص صحة الخصم المتبقية بعد إنقاصها، وذلك للتأكد من وصولها للقيمة صفر من عدمه؛ حيث يجب أن تنتهي اللعبة في تلك الحالة.

في حال استمرت اللعبة ولم تنته فإنه يتم تنفيذ الأسطر 257 إلى 261، واليت يتم فيها المرور على جميع مواقع البطاقات وزيادة قيمة قوة البطاقة الموجودة بأيدي اللاعبين بمقدار واحد لكل بطاقة، وذلك عبر استدعاء الدّالة ()IncrementPower. إضافة لذلك يتم في السطر 265 سحب بطاقة جديدة من أجل إعطائها للاعب الذي قام برمي البطاقة الحالية. إذا وجدت هذه الطاقة، بمعنى أن القيمة المرجعة من ()DrawNewCard لا تساوي null، فإن الأسطر 267 إلى 286 يتم تنفيذها حيث يتم المرور على مواقع البطاقات مجددا من أجل إيجاد الموقع الفارغ ووضع البطاقة المسحوبة فيه.

قبل الانتهاء من تنفيذ الدّالة ()OnCardThrowComplete عليها أن تقوم بفحص ما إذا كانت جميع البطاقات في مجموعة اللعب قد تم رميها، وهو ما يتم عبر استدعاء الدّالة ()AllCardsThrown. هذه الّدالة تقوم بإرجاع true في حال كانت جميع البطاقات قد رميت فعلا، وفي تلك الحالة يتم استدعاء الدّالة ()EndGame من أجل إنهاء اللعبة. في حال لم يتم رمي جميع البطاقات بعد فإنه يجب تجهيز دور اللعب المقبل بنقله التحكم للاعب الآخر، وهو ما يتم عبر تغيير قيمة playerTurn لتصبح عكس قيمتها الحالية، إضافة لاستدعاء ()ExitWaitMode لإنهاء حالة الانتظار وإرسال الرسالة TurnEnded لإعلام البريمجات الأخرى بنقل الدور.

الدّالة ()AllCardsThrown والمعرفة في الأسطر 322 إلى 331 تحدد ما إذا كانت جميع بطاقات مجموعة اللعب قد تم رميها، وذلك عن طريق المرور على جميع مواقع البطاقات المخزنة في المصفوفة []cardHolders. إن كانت جميع المواقع فارغة من أية بطاقات فإن هذا يعني أن البطاقات كلها قد رميت فعلا بالتالي يتم إرجاع القيمة true. في حال كان هناك موقع واحد غير فارغ على الأقل فإن هذا يكفي لنفي رمي جميع البطاقات وبالتالي يتم إرجاع false. الدّالة الثانية التي يمكن أن تستدعيها ()OnCardThrowComplete هي ()EndGame والمعرفة في الأسطر 306 إلى 319 والتي ستقوم بتغيير قيمة gameEnded إلى true. إضافة لذلك فإنّها تقوم بحساب نتيجة الجولة على شكل عدد صحيح، حيث يعني الرقم 1 بأنّ اللاعب قد فاز على الذكاء الاصطناعي ويعني الرقم صفر التعادل والرقم 1- يعني بأنّ اللاعب قد خسر. أخيرا فإن هذه الدّالة تقوم بإرسال الرسالة GameEnded مرفقة بنتيجة الجولة.

بالرجوع إلى قواعد اللعبة نتذكر أن الخيار الآخر المتاح للمشارك صاحب الدور الحالي هو تمرير دوره للاعب الآخر دون أن يقوم برمي أية بطاقة. في هذه الحالة فإن اللاعب يقوم باستدعاء الدّالة ()PerformPass المعرفة في الأسطر من 172 إلى 195. هذه الدّالة تستقبل متغيرا واحدا هو isPlayer والذي يحدد إذا ما كان اللاعب البشري هو من يمرر الدور أم أنه الذكاء الاصطناعي. قبل تمرير الدور تتأكد الدّالة من أن اللاعب الذي يحاول أن يمرر الدور هو أصلا صاحب الدور الحالي في اللعب وذلك عن طريق مطابقة قيمتي المتغيرين isPlayer و playerTurn. فإذا تساوت القيمتان يمكن حينها تمرير الدور وذلك عن طريق عكس قيمة playerTurn ومن ثم زيادة قوة بطاقات اللاعب الذي قام بالتمرير فقط. أخيرا فإن الدّالة تدخل اللعبة في طور الانتظار لمدة ثانية عن طريق استدعاء ()EnterWaitMode متبوعة باستدعاء ()ExitWaitMode بعدها بثانية واحدة. هناك دوال أخرى في هذا البريمج سنناقشها بعد قليل، لكننا الآن سننتقل لبريمج آخر.

بالعودة إلى قالب بطاقة اللعب نتذكر بأنّ هذا القالب يحتوي على البريمج PlayCard والذي يحدد الوظائف الأساسية لبطاقة اللعب. ما ينقص القلب هو تحديد كيفية تفاعل اللاعب مع البطاقة: كيف يرميها وكيف يمرر الدور. من أجل ذلك نحن بحاجة لإضافة بريمج آخر لقالب البطاقة وهو CardInputHandler والذي يقوم بمهمة استقبال مدخلات اللاعب. هذا البريمج موضح في السرد التالي.

1. using UnityEngine;
2. using System.Collections;
3. 
4. //يقوم هذا البريمج بقراءة مدخلات اللاعب
5. public class CardInputHandler : MonoBehaviour {
6.  
7.  //مرجع لبريمج بطاقة اللعب
8.  PlayCard card;
9.  
10.     //مرجع لبريمج إدارة اللعبة
11.     CardGameManager manager;
12.     
13.
14.     void Start () {
15.         card = GetComponent<PlayCard>();
16.         manager = FindObjectOfType<CardGameManager>();
17.     }
18.     
19.
20.     void Update () {
21.         if(card.isPlayerCard && manager.CanPlayerPlay()){
22.             if(Input.GetKeyDown(KeyCode.Space)){
23.                 manager.PerformPass(true);
24.             }
25.         }
26.     }
27.     
28.     void OnMouseUpAsButton(){
29.         if(card.isPlayerCard && manager.CanPlayerPlay()){
30.             card.ThrowCard();
31.         }
32.     }
33. }

البريمج الخاص باستقبال مدخلات اللاعب على بطاقة اللعب

يعتمد هذا البريمج على مرجع للبريمج CardGameManager المسؤول عن إدارة اللعبة حيث يحتاج للتأكد من السماح للاعب باللعب من عدمه، وذلك عن طريق استدعاء الدّالة ()CanPlayerPlay، هذه الدّالة معرفة في CardGameManager في الأسطر 162 إلى 164 وهي تقوم بفحص ثلاثة شروط لتقرر ما إذا كان يسمح للاعب باللعب أم لا. هذه الشروط الثلاثة هي: أن الجولة لم تنتهي بعد، وأنه دور اللاعب ليلعب، وأن اللعبة ليست في طور الانتظار.إذا التقت هذه الشروط الثلاثة فإنه يسمح للاعب باللعب، كذلك الأمر بالنسبة للذكاء الاصطناعي مع الدّالة ()CanCpuPlay المعرفة في الأسطر 167 إلى 169.

خلال دورة التحديث يقوم هذا البريمج بفحص حالة مفتاح المسافة، فإذا تم ضغط هذا المفتاح فإنه يقوم باستدعاء الدّالة ()PerformPass من CardGameManager، وذلك من أجل تمرير الدور. إضافة لذلك فإن الدّالة ()OnMouseUpAsButton يتم تنفيذها في حال الضغط بزر الفأرة فوق البطاقة، وبالتالي يتم استدعاء الدّالة ()ThrowCard من أجل أن يتم رمي البطاقة التي ضغط عليها اللاعب.

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

1. using UnityEngine;
2. using System.Collections.Generic;
3. 
4. //يقوم هذا البريمج باتخاذ قرارات الذكاء الاصطناعي أثناء اللعب
5. [RequireComponent(typeof(CardGameManager))]
6. public class CardGameDecisionTree : MonoBehaviour {
7.  
8.  //مرجع إلى بريمج إدارة اللعبة
9.  CardGameManager manager;
10.     
11.     //متغيرات الحالة
12.     
13.     //العدد الأصلي من البطاقات في مجموعة البطاقات
14.     int originalHeals, originalAttacks;
15.     
16.     //متغيرات لعد البطاقات التي تم رميها
17.     int thrownHeals, thrownAttacks;
18.     
19.     //عدد الأدوار التي لُعبت حتى الآن
20.     int turnsCount;
21.     
22.     //متغيرات لتتبع صحة كلا اللاعبين
23.     int playerHealth, myHealth;
24.     
25.     
26.     void Start () {
27.         ResetStats();
28.     }
29.     
30.     //تقوم بإعادة ضبط جميع المتغيرات الإحصائية لقيمها الأصلية
31.     void ResetStats(){
32.         turnsCount = 0;
33.         thrownHeals = 0;
34.         thrownAttacks = 0;
35.     
36.         manager = GetComponent<CardGameManager>();
37.         
38.         originalHeals = manager.healCards;
39.         originalAttacks = manager.attackCards;
40.     }
41.     
42.     //تضبط المتغيرات عند بداية اللعبة
43.     void GameStarted(){
44.         ResetStats();
45.     }
46.     
47.     
48.     void Update () {
49.         UpdateStats();
50.         MakeDecision();
51.     }
52.     
53.     //تقوم بتحديث متغيرات الحالة المستخدمة للإدراك
54.     void UpdateStats(){
55.         myHealth = manager.GetCpuHealth();
56.         playerHealth = manager.GetPlayerHealth();
57.     }
58.     
59.     //PassPerformed تستقبل الرسالة 
60.     //المرسلة من قبل مدير اللعبة
61.     void PassPerformed(bool playerPass){
62.         //قم بزيادة عداد الأدوار
63.         turnsCount++;
64.     }
65.     
66.     //TurnEnded يقوم بالتعامل مع رسالة
67.     //بناء على نوع البطاقة التي تم رميها
68.     void TurnEnded(PlayCard thrownCard){
69.         turnsCount++;
70.         if(thrownCard.cardType == PlayCard.HEAL){
71.             thrownHeals++;
72.         } else if(thrownCard.cardType == PlayCard.ATTACK){
73.             thrownAttacks++;
74.         }
75.     }
76.     
77.     //تقوم بإيجاد البطاقة الأقوى
78.     //بين البطاقات الثلاث التي يملكها
79.     PlayCard FindStrongestCard(int cardType){
80.         
81.         List<PlayCard> myCards = manager.GetCpuCards();
82.         
83.         PlayCard strongestCard = null;
84.         foreach(PlayCard card in myCards){
85.             if(card.cardType == cardType){
86.                 if(strongestCard == null ||
87.                     card.GetPower() > strongestCard.GetPower()){
88.                     strongestCard = card;
89.                 }
90.             }
91.         }
92.         
93.         return strongestCard;
94.     }
95.     
96.     //شجرة اتخاذ القرارات
97.     void MakeDecision(){
98.         //جذر الشجرة
99.         if(manager.CanCpuPlay()){
100.            //حان دور الذكاء الاصطناعي للعب
101.            //وعليه أولا أن يتعرف على ما لديه من بطاقات
102.            
103.            //قم بإيجاد البطاقة الأقوى من كل نوع
104.            PlayCard bestAttackCard = FindStrongestCard(PlayCard.ATTACK);
105.            PlayCard bestHealCard = FindStrongestCard(PlayCard.HEAL);
106.            
107.            //القرار الأول هو الفوز باللعبة برمي بطاقة
108.            //هجوم واحدة إن كان ذلك ممكنا
109.            bool oneAttackWinning = bestAttackCard != null &&
110.                bestAttackCard.GetPower() >= playerHealth;
111.            
112.            if(oneAttackWinning){
113.                bestAttackCard.ThrowCard();
114.            } else {
115.                //الأمر الثاني المهم هو كون الذكاء
116.                //الاصطناعي في وضع صحي يحتاج
117.                //فيه لعلاج سريع تجنبا للخسارة
118.                bool lowHealth = myHealth < playerHealth - 2;
119.                if(lowHealth){
120.                    //هناك خطر ينذر بخسارة
121.                    //اللعبة، لذا سيكون العلاج
122.                    //أولوية في هذه الحالة
123.                    if(bestHealCard != null){
124.                        bestHealCard.ThrowCard();
125.                    } else {
126.                        //هجوم يائس
127.                        bestAttackCard.ThrowCard();
128.                    }
129.                } else {
130.                    //لا يوجد خطر يتعلق
131.                    //بخسارة الصحة لذا يجب
132.                    //رمي البطاقة الأقوى أو تمرير
133.                    //الدور لتقوية البطاقات إن كانت اللعبة في أولها
134.                    if(turnsCount < 4){
135.                        manager.PerformPass(false);
136.                    } else {
137.                        ThrowCard(bestAttackCard, bestHealCard);
138.                    }
139.                }
140.            }
141.        }
142.    }
143.    
144.    //تقوم برمي بطاقة واحدة من البطاقتين المعطاتين
145.    void ThrowCard(PlayCard attackCard, PlayCard healCard){
146.        int attackPower = 0, healPower = 0;
147.        
148.        if(attackCard != null){
149.            attackPower = attackCard.GetPower();
150.        }
151.        
152.        if(healCard != null){
153.            healPower = healCard.GetPower();
154.        }
155.        
156.        if(attackPower == 0 && healPower == 0){
157.            //لم يتبق أي بطاقات لرميها
158.            manager.PerformPass(false);
159.            return;
160.        }
161.        
162.        if(healPower > attackPower){
163.            if(bestAttackCard != null){
164.                bestAttackCard.ThrowCard();
165.            } else {
166.                bestHealCard.ThrowCard();
167.            }
168.        } else if(healPower < attackPower){
169.            attackCard.ThrowCard();
170.        } else {
171.            //البطاقتان ذواتا قوة متساوية
172.            //لذا نقرر الاحتفاظ بالبطاقة
173.            //الأكثر ندرة فيما تبقى
174.            int remainingHeals = originalHeals - thrownHeals;
175.            int remainingAttacks = originalAttacks - thrownAttacks;
176.            
177.            if(remainingHeals < remainingAttacks){
178.                attackCard.ThrowCard();
179.            } else if(remainingHeals > remainingAttacks){
180.                healCard.ThrowCard();
181.            } else {
182.                //البطاقات المتبقية متساوية بين النوعين
183.                //فنختار عشوائيا
184.                int throwHeal = Random.Range(0, 2);
185.                if(throwHeal == 1){
186.                    healCard.ThrowCard();
187.                } else {
188.                    attackCard.ThrowCard();
189.                }
190.            }
191.        }
192.    }
193. }

شجرة اتخاذ القرارات في لعبة البطاقات

تتبع شجرة اتخاذ القرارات في السرد السابق الخطوات التي ذكرناها في الفصل الأول وهي الإدراك، واتخاذ القرار، والتنفيذ. حيث لدينا المتغيران originalHeals و originalAttacks اللذان يخزنان أعداد البطاقات المتوفرة من كل نوع. إضافة لذلك نقوم بتتبع عدد الأوراق التي تم رميها أثناء اللعب عبر المتغيرين thrownHeals و thrownAttacks. بعد انتهاء كل دور لعب نقوم بزيادة قيمة المتغير turnsCount والذي يعمل على عدّ أدوار اللعب منذ بداية اللعبة وحتى الدور الحالي. أخيرا لدينا المتغيران playerHealth و myHealth واللذان يستخدمهما الذكاء الاصطناعي لتتبع قيمة صحته الخاصة (myHealth) وقيمة صحة اللاعب (playerHealth). كل هذه المتغيرات التي ذكرتها هي متغيرات تعبر عن الخطوة الأولى وهي الإدراك؛ حيث أنها تمثل معلومات هامّة يجب معرفتها من أجل اتخاذ القرار الصحيح في كل دور لعب.

عندما تبدأ اللعبة فإنّ هذا البريمج يقوم باستقبال الرسالة GameStarted والتي يرسلها CardGameManager، ولهذا السبب فإنه من الضروري أن تتم إضافة هذا البريمج إلى الكائن الجذري. الدّالتان ()Start و ()GameStarted تعملان على استدعاء دالّة ثالثة هي ()ResetStats والمعرفة في الأسطر 31 إلى 40. هذه الدّالة تعمل على إعادة ضبط جميع متغيرات الإدراك إلى قيمها الأصلية. أثناء اللعب يتم تنفيذ الدّالة ()Update كما هو معروف لدينا، وهي تعمل هنا على استدعاء كل من ()UpdateStats و ()MakeDecision. الدّالة ()UpdateStats والمعرفة في الأسطر 54 إلى 57 تنفذ جزءا من عملية الإدراك وذلك عبر تحديث قيم المتغيرين myHealth و playerHealth. ضمن عملية الإدراك أيضا تقوم ()PassPerfoermed باستقبال الرسالة PassPerformed كما تقوم ()TurnEnded باستقبال الرسالة التي تحمل نفس الاسم. تقوم هاتان الدّالتان بزيادة قيمة turnsCount. الاختلاف بينهما هو أن الدّالة ()TurnEnded تقوم بزيادة إحدى القيمتين thrownHeals أو thrownAttacks وذلك تبعا للبطاقة التي تم رميها في الدور السابق. كل هذه الدّوال والخطوات التي تنفذها تشكل معا عملية الإدراك، وهي التي يجب أن تتم قبل البدء في اتخاذ القرار.

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

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

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

تتكون الشجرة من مجموعة من العقد تحتوي على أسئلة، وهناك حواف تربط هذه العقد ببعضها عن طريق الإجابات المحتملة لكل سؤال. المعلومات اللازمة لإجابة الأسئلة هي المعلومات التي تم جمعها في مرحلة الإدراك. كما تلاحظ فإن الأسئلة المكتوبة في الشكل هي أسئلة مجرّدة، إلا أنه يمكنك دراسة الدّالة ()MakeDecision في الأسطر من 97 إلى 146 من أجل فهم المعنى الكامل لهذه الأسئلة. لنحاول معا على سبيل المثال تحليل معنى السؤال "هل صحتي منخفضة؟"، بالرجوع إلى الدّالة ()MakeDecision وتحديدا السطر 122 ستجد أن هذا السؤال معناه: هل صحة اللاعب أعلى من صحتي بمقدار يتجاوز 2؟ تلاحظ أيضا أن جذر الشجرة والموجود في أقصى اليسار يمثل الأولوية الأعلى، وهي قواعد اللعبة، والتي لا يجوز تجاوزها تحت أي ظرف حتى لو أدى للخسارة. بالتالي إن تبين أنه ليس دور الذكاء الاصطناعي في اللعب فإن الشجرة تنتهي فورا ولا تتخذ أي قرار أو تنفذ أي شيء.

باختصار فإن الشجرة تحاول أن تنهي اللعبة لصالح الذكاء الاصطناعي عن طريق رمي بطاقة واحدة، فإن لم يكن هذا ممكنا فإنها تفحص إمكانية كون صحة الذكاء الاصطناعي منخفضة، وفي هذه الحالة تقوم برمي بطاقة صحة - إن توفرت - من أجل تجنب خسارة اللعبة. إن لم يتحقق أي من هذين الشرطين فإنها تقوم بتمرير الدور إن كان ذلك مناسبا أو تقرر أن ترمي بطاقة. في حال قررت الشجرة رمي بطاقة فإنها تستدعي الدّالة ()ThrowCard في الأسطر 149 إلى 193 وهذه بدورها هي شجرة أخرى فرعية. عند استدعاء هذه الدّالة يتم تزويدها بأعلى بطاقة هجوم وأعلى بطاقة صحة متوفرتين لدى الذكاء الاصطناعي، ومهمتها تقرير أي البطاقتين ستُرمى. شجرة اتخاذ القرارات الخاصة برمي البطاقة الأنسب مبينة في الشكل التالي.

شجرة اتخاذ قرار رمي البطاقة

شجرة اتخاذ قرار رمي البطاقة

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

البريمج الأخير الذي سنتناوله في هذا الفصل هو بريمج مساعد وليس أساسيا، واسمه CardGameHUD. يجب إضافة هذا البريمج الموضح في السرد أدناه إلى الكائن الجذري حيث أنه يحتاج CardGameManager ليعمل. يحتوي هذا البريمج على مجموعة متغيرات من نوع TextMesh يقوم من خلالها بالوصول إلى مجموعة كائنات نص ثلاثي الأبعاد ليقوم بعرض حالة اللعبة عليها. هذه النصوص تشمل صحة كل من اللاعب والذكاء الاصطناعي إضافة إلى تعليمات اللعب للاعب. تلاحظ أن هذا البريمج بسيط ووظائفه مباشرة تخلوا من أي تعقيد. الجدير بالذكر أن كائنات النص ثلاثي الأبعاد كلها مضافة كأبناء للكائن الفارغ HUDTexts من أجل الحفاظ على الترتيب. يمكنك مشاهدة المثال النهائي لهذا الفصل في المشهد Assets/Chapter6/Section2/DecisionTrees.unity في المشروع المرفق.

1. using UnityEngine;
2. using System.Collections;
3. 
4. //يقوم هذا البريمج بإدارة نصوص واجهة المستخدم
5. [RequireComponent(typeof(CardGameManager))]
6. public class CardGameHUD : MonoBehaviour {
7. 	
8. 	//كائنات النص ثلاثي الأبعاد المستخدم لأغراض مختلفة
9. 	public TextMesh playerHealthDisplay, 	//تعرض صحة اللاعب
10. 					cpuHealthDisplay,		//تعرض صحة الذكاء الاصطناعي
11. 					nextTurnDisplay,		//تعرض صاحب الدور الحالي في اللعب
12. 					remainingCardsDisplay,	//تعرض عدد البطاقات المتبقية
13. 					instructionsDisplay,	//تعرض تعليمات اللعب
14. 					passDisplay;			//يظهر هذا النص حال تمرير الدور
15. 	
16. 	//مرجع لكائن صحة اللاعب
17. 	CardGameManager manager;
18. 	
19. 	
20. 	void Start () {
21. 		manager = GetComponent<CardGameManager>();
22. 	}
23. 	
24. 	
25. 	void Update () {
26. 		//قم بتحديث معلومات واجهة المستخدم
27. 		//طالما أن اللعب لم ينته
28. 		if(!manager.GameEnded()){
29. 			UpdateHUDState();
30. 		}
31. 	}
32. 	
33. 	//تقوم بالتعامل مع حدث بداية اللعبة
34. 	void GameStarted(){
35. 		//قم بإخفاء نص التمرير
36. 		passDisplay.renderer.enabled = false;
37. 	}
38. 	
39. 	//تقوم بالتعامل مع حدوث تمرير للدور
40. 	void PassPerformed(bool playerPass){
41. 		//تعرض نص التمرير لوقت قصير
42. 		passDisplay.renderer.enabled = true;
43. 		if(playerPass){
44. 			passDisplay.text = "YOU PASS!";
45. 		} else {
46. 			passDisplay.text = "CPU PASS!";
47. 		}
48. 		Invoke("HidePassDisplay", 1.0f);
49. 	}
50. 	
51. 	//تقوم بإخفاء نص التمرير
52. 	void HidePassDisplay(){
53. 		passDisplay.renderer.enabled = false;
54. 	}
55. 	
56. 	//تتعامل مع حدث نهاية اللعبة ونتيجتها
57. 	void GameEnded(int gameResult){
58. 		UpdateHUDState();//تحديث لآخر مرة
59. 		
60. 		if(gameResult == 0){
61. 			nextTurnDisplay.text = "Game Over: Draw! Press space to restart";
62. 		} else if(gameResult > 0){
63. 			nextTurnDisplay.text = "Game Over: You Win! Press space to restart";
64. 		} else if(gameResult < 0) {
65. 			nextTurnDisplay.text = "Game Over: You Lose! Press space to restart";
66. 		}
67. 	}
68. 	
69. 	void UpdateHUDState(){
70. 		playerHealthDisplay.text = "YOU: " + manager.GetPlayerHealth().ToString();
71. 		cpuHealthDisplay.text = "CPU: " + manager.GetCpuHealth().ToString();
72. 		
73. 		if(manager.IsPlayerTurn()){
74. 			nextTurnDisplay.text = "Your turn";
75. 			instructionsDisplay.text = 
76. 									"Click a card to throw it, or space to pass";
77. 		} else {
78. 			nextTurnDisplay.text = "CPU Turn";
79. 			instructionsDisplay.text = "Wait for CPU to play";
80. 		}
81. 		
82. 		remainingCardsDisplay.text = "Cards Remaining: " + 
83. 										manager.RemainingCardsCount();
84. 	}
85. }

بريمج لتحديث نصوص واجهة المستخدم بناء على حالة اللعبة

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

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

تعليق واحد على “الفصل الثاني: شجرة اتخاذ القرارات وأولوياتها

  1. مقالات ذات جودة عالية ماشاء الله
    أفدتنا يا سيد
    ليت لديك نشرات بريدية

التعليقات مغلقة.