الحالة
^title State ^section Design Patterns Revisited
وقت الاعتراف: لقد أفرطت قليلاً في هذا الفصل، وحشوته بمحتوى أكثر مما ينبغي. فهو ظاهراً عن نمط الحالة (State)، لكنني لا أستطيع الحديث عن ذلك وعن الألعاب دون التطرق إلى المفهوم الأكثر أساسية، وهو آلة الحالات المنتهية (finite state machine) أو «آلات الحالات المنتهية» (FSM). لكن ما إن خطوت إلى هناك حتى رأيت أن من الأفضل أن أقدّم أيضاً آلات الحالات الهرمية (hierarchical state machines) والمآلات الدفعية (pushdown automata).
هذا قدرٌ كبير لتغطيته، ولذلك سأبقي الأمر في أقصر صورة ممكنة، فأمثلة الشيفرة هنا تُهمل بعض التفاصيل التي عليك ملء الفراغات فيها بنفسك. آمل أن تبقى واضحة بما يكفي لتريك الصورة الكبرى.
لا تحزن إن لم تسمع من قبل بآلة الحالات. فرغم أنها معروفة جيداً لدى عُتاة الذكاء الاصطناعي والمترجِّمين، إلا أنها ليست كذلك مألوفة لدى بقية الأوساط البرمجية. وأعتقد أنها ينبغي أن تكون معروفة على نحو أوسع، لذلك سأطرحها هنا على نوع مختلف من المشكلات.
مرّينا جميعاً بهذا
نحن نعمل على لعبة منصّات جانبية بسيطة. ومهمتنا هي تنفيذ البطلة التي تلعب دور الشخصية التي يتحكم بها اللاعب في عالم اللعبة. أي أننا نجعلها تستجيب لمدخلات اللاعب. فإذا ضغط اللاعب زر B فينبغي أن تقفز. الأمر بسيط بما يكفي:
^code spaghetti-1
أرأيت الخلل؟
لا يوجد ما يمنع «القفز في الهواء» — فإذا واصلت الضغط على B أثناء وجودها في
الهواء، فإنها ستبقى معلّقة إلى الأبد. والإصلاح
البسيط هو إضافة حقل isJumping_ من النوع المنطقي إلى Heroine يتتبّع متى تكون
قفزاً، ثم نفّذ الآتي:
^code spaghetti-2
بعد ذلك، نريد للبطلة أن تنخفض إذا ضغط اللاعب إلى أسفل أثناء وجودها على الأرض، وتقوم من جديد عند الإفلات عن الزر:
^code spaghetti-3
أرأيت الخلل هذه المرة؟
مع هذه الشيفرة، يستطيع اللاعب أن:
- يضغط إلى أسفل فتنخفض.
- يضغط B فيقفز من وضعية الانخفاض.
- يُفلت «الأسفل» وما زال في الهواء.
وستنتقل البطلة إلى صورتها الواقفة في منتصف القفزة. حان وقت علم راية أخرى...
^code spaghetti-4
بعد ذلك، سيكون رائعاً لو أن البطلة نفّذت هجوم غطس إذا ضغط اللاعب إلى أسفل في منتصف القفزة:
^code spaghetti-5
حان وقت مطاردة الأخطاء من جديد. هل عثرت عليها؟
نحن نتحقق من أنه لا يمكنها القفز في الهواء أثناء القفز، لكننا لا نتحقق من ذلك أثناء الغطس. حقل آخر...
يبدو أن في نهجنا خللاً واضحاً. فكلما مسسنا هذه القلّة من الشيفرة، كسرنا شيئاً ما. ونحن بحاجة إلى إضافة حركات كثيرة — لم نضف حتى الآن المشي — لكن بهذا المعدل، سينهار الأمر إلى كومة من الأخطاء قبل أن ننتهي منه.
آلات الحالات المنتهية لإنقاذنا
في نوبة من الإحباط، سويت كل شيء عن مكتبك باستثناء قلم وورقة وبدأت أرسم مخططاً انسيابياً. فترسم صندوقاً لكل شيء يمكن أن تكون البطلة تفعله: واقفة، قافزة، منخفضة، وغاطسة. وعندما تستطيع الاستجابة لضغطة زر في إحدى تلك الحالات، ترسم سهماً من ذلك الصندوق، وتُسمّيه باسم الزر، وتربطه بالحالة التي تنتقل إليها.
مبروك، لقد أنشأت للتو آلة حالات منتهية. لقد جاءت هذه من فرع في علوم الحاسوب يسمى نظرية الأوتوماتا (automata theory)، وعائلة هياكل البيانات فيها تضمّ أيضاً آلة تورينغ الشهيرة. وآلات الحالات المنتهية هي أبسط أعضاء تلك العائلة.
الخلاصة هي:
-
لديك مجموعة حالات ثابتة يمكن أن تكون الآلة فيها. وفي مثالنا، تلك هي: الواقفة، والقافزة، والمنخفضة، والغاطسة.
-
لا يمكن للآلة أن تكون في أكثر من حالة واحدة في الوقت نفسه. فلا يمكن لبطلتنا أن تقفز وتكون واقفة في آن واحد. وفي الواقع، منع ذلك هو أحد الأسباب التي سنستخدم لأجلها آلة حالات منتهية.
-
تُرسَل إلى الآلة سلسلة من المدخلات أو الأحداث. وفي مثالنا، تلك هي ضغطات الأزرار وإفلاتها الخام.
-
لكل حالة مجموعة انتقالات (transitions)، يرتبط كل انتقال بمدخل ما ويشير إلى حالة ما. وعندما يأتي مدخل، فإن طابق انتقالاً للحالة الحالية، غيّرت الآلة حالتها إلى الحالة التي يشير إليها ذلك الانتقال.
على سبيل المثال، الضغط إلى أسفل أثناء الوقوف ينتقل بك إلى حالة الانخفاض. والضغط إلى أسفل أثناء القفز ينتقل بك إلى حالة الغطس. وإذا لم يُعرَّف أي انتقال لمدخل ما على الحالة الحالية، فإن ذلك المدخل يُتجاهل.
في صورتها المجرّدة، هذه هي القصة كلها: حالات ومدخلات وانتقالات. ويمكنك رسمها كأنها مخطط انسيابي صغير. وللأسف، لا يتعرّف المترجِّم على همساتنا، فكيف نقوم إذاً بتنفيذ واحدة؟ إن نمط الحالة لدى «الرباعية» إحدى الطرق — وسنصل إليها — لكن لنبدأ بالأسهل.
التعدادات وأوامر switch
إحدى المشكلات في صنف Heroine عندنا هي أن بعض توليفات تلك الحقول المنطقية غير
صالحة: فـisJumping_ وisDucking_ مثلاً لا ينبغي أن يكونا true في الوقت نفسه
أبداً. وحين تكون لديك حفنة من الرايات لا يكون منها سوى واحدة true في الوقت
ذاته، فهذه إشارة إلى ما تريده حقاً هو enum أي تعداد.
في هذه الحالة، يكون ذلك enum هو بالضبط مجموعة الحالات لآلة حالاتنا المنتهية،
فلنعرّفها إذاً:
^code enum
بدلاً من حفنة الرايات، سيكون لدى Heroine حقل واحد فقط هو state_. كما أننا
نعكس ترتيب التفرّع لدينا. في الشيفرة السابقة، كنا نتفرّع على المدخل، ثم على
الحالة. وقد أبقى ذلك شيفرة معالجة ضغطة زر واحدة معاً، لكنه شتّت شيفرة حالة واحدة
في مواضع متفرّقة. لكننا نريد إبقاءها معاً، لذلك نتفرّع على الحالة أولاً. فنحصل
على الآتي:
^code state-switch
قد يبدو هذا تافهاً، لكنه تحسّن حقيقي عن الشيفرة السابقة. فما زال لدينا بعض التفرّع الشرطي، لكننا بسّطنا الحالة القابلة للتغيّر إلى حقل واحد. وأصبحت كل شيفرة معالجة حالة واحدة مجمّعة بإتقان في موضع واحد. وهذه أبسط طريقة لتنفيذ آلة حالات، وهي صالحة لبعض الاستعمالات.
غير أن مشكلتك قد تكبر عن هذا الحل. فلنفترض أننا نريد إضافة حركة تستطيع فيها بطلتنا الانخفاض لبعض الوقت كي تشحن الطاقة وتطلق هجوماً خاصاً. وأثناء انخفاضها، نحتاج إلى تتبّع زمن الشحن.
نضيف حقل chargeTime_ إلى Heroine لتخزين مدة شحن الهجوم. ولنفترض أننا لدينا
بالفعل update() تُستدعى في كل إطار. وفي داخلها نضيف:
^code switch-update
نحتاج إلى تصفير المؤقّت عندما تبدأ بالانخفاض، لذا نعدّل handleInput():
^code state-switch-reset
بالحساب الكلّي، لإضافة هذا الهجوم المشحون، اضطررنا إلى تعديل دالتين وإضافة حقل
chargeTime_ إلى Heroine رغم أنه لا معنى له إلا في حالة الانخفاض وحدها. وما
نودّه هو أن يكون كل تلك الشيفرة والبيانات ملفوفاً بإتقان في موضع واحد. أما
«الرباعية» فقد غطّتنا الأمر.
نمط الحالة
للأشخاص الغارقين في العقلية الكينونية، فإن كل تفرّع
شرطي يمثل فرصة لاستخدام التوزيع الديناميكي (بعبارة أخرى، استدعاء دالة افتراضية
في ++C). وأعتقد أن بإمكانك أن تذهب في ذلك الاتجاه أبعد مما ينبغي. فأحياناً لا
تحتاج سوى if.
لكن في مثالنا، بلغنا نقطة فاصلة يصبح عندها شيء كينوني أنسب. وهذا يقودنا إلى نمط الحالة. وبعبارة «الرباعية»:
اسمح لكائن بأن يغيّر سلوكه عندما تتغيّر حالته الداخلية. وسيبدو الكائن وكأنه يغيّر صنفه.
هذا لا يخبرنا بالكثير. يا تري، لدينا switch يفعل ذلك. أما النمط الملموس الذي
يصفونه فيبدو هكذا عند تطبيقه على بطلتنا:
واجهة للحالة
أولاً، نعرّف واجهة للحالة. كل جزء من السلوك يعتمد على الحالة — وكل موضع كان
لنا فيه switch من قبل — يصبح دالة افتراضية في تلك الواجهة. وبالنسبة لنا، تلك
هي handleInput() وupdate():
^code heroine-state
أصناف لكل حالة
لكل حالة، نعرّف صنفاً يطبّق الواجهة. وتحدد دواله سلوك البطلة عندما تكون في تلك
الحالة. وبعبارة أخرى، خذ كل case من جمل switch السابقة وانقلها إلى صنف
حالتها. على سبيل المثال:
^code ducking-state
لاحظ أننا نقلنا أيضاً chargeTime_ من Heroine إلى صنف DuckingState. وهذا
رائع — فتلك القطعة من البيانات لا معنى لها إلا أثناء وجودنا في تلك الحالة،
وآن نموذج كائناتنا يعكس ذلك صراحةً.
التفويض إلى الحالة
بعد ذلك، نمنح Heroine مؤشراً إلى حالتها الحالية، ونتخلّص من كل switch كبير،
ونفوّض إلى الحالة بدلاً من ذلك:
^code gof-heroine
كي «نغيّر الحالة»، لا نحتاج سوى أن نُسند state_ ليشير إلى كائن HeroineState
آخر. وهذا هو نمط الحالة بكامله.
أين كائنات الحالة؟
لقد أخطأت في تمرير نقطة واحدة هنا. فلتغيير الحالات، نحتاج إلى إسناد state_
ليشير إلى الحالة الجديدة، لكن من أين يأتي ذلك الكائن؟ ومع تنفيذنا بـenum كان
ذلك أمراً بديهياً — فقيم enum عناصر أولية مثل الأرقام. لكن حالاتنا صارت الآن
أصنافاً، وهذا يعني أننا نحتاج إلى نسخة فعلية نشير إليها. وهناك جوابان شائعان
لهذا:
حالات ساكنة (static)
إذا لم يكن لكائن الحالة أي حقول أخرى، فالبيانات الوحيدة التي يخزّنها هي مؤشر إلى جدول الدوال الافتراضية الداخلية كي يمكن استدعاء دواله. وفي هذه الحالة، لا يوجد أي سبب في العالم لتجاوز نسخة واحدة منه. فكل النسخ ستكون متطابقة على أي حال.
في هذه الحالة، يمكنك صنع نسخة ساكنة واحدة. فحتى لو كان لديك كثير من آلات الحالات المنتهية تعمل جميعاً في الوقت نفسه وفي الحالة نفسها، يمكنها جميعاً أن تشير إلى النسخة نفسها لأنها لا تحمل شيئاً خاصاً بآلة بعينها.
أين تضع تلك النسخة الساكنة فالأمر أمرك. اختر موضعاً منطقياً. ولسبب غير بعينه، دعنا تضع نسختنا داخل صنف الحالة الأساس:
^code heroine-static-states
كل واحد من تلك الحقول الساكنة هو النسخة الوحيدة من تلك الحالة التي تستخدمها اللعبة. وكي تجعل البطلة تقفز، ستقوم حالة الوقوف بشيء من قبيل:
^code jump
حالات مُنشأة
أحياناً، مع ذلك، لا ينفع هذا. فلن تنفع حالة ساكنة لحالة الانخفاض. فحالتها تحتوي
حقل chargeTime_، وذلك خاص بالبطلة التي تحالف الانخفاض في تلك اللحظة. وقد ينجح
هذا عرضاً في لعبتنا إن كانت هناك بطلة واحدة فقط، لكن إن حاولنا إضافة تعاون بين
لاعبَين مع وجود بطليتين على الشاشة في الوقت نفسه، فسنواجه مشكلات.
في هذه الحالة، علينا أن نُنشئ كائن حالة عند الانتقال
إليه. هذا يتيح لكل آلة حالات منتهية أن يكون لها نسختها الخاصة من تلك الحالة. وبالطبع،
إذا كنا نخصّص حالة جديدة، فذلك يعني أننا بحاجة إلى تحرير الحالية. ويجب أن نكون
حذرين هنا، لأن الشيفرة التي تُطلق التغيير موجودة في دالة داخل الحالة الحالية. ولا
نريد أن نحذف this من تحت أقدامنا.
بدلاً من ذلك، سنسمح لـhandleInput() في HeroineState بأن تعيد حالة جديدة
اختيارياً. وعندما تفعل ذلك، ستحذف Heroine القديمة وتستبدل بالجديدة، هكذا:
^code swap-instance
بهذه الطريقة، لا نحذف الحالة السابقة حتى نكون قد عدنا من دالتها. الآن، تستطيع حالة الوقوف أن تنتقل إلى الانخفاض عبر إنشاء نسخة جديدة:
^code duck
عندما أستطيع، أفضّل استخدام الحالات الساكنة لأنها لا تحرق الذاكرة ودورات المعالج في تخصيص الكائنات عند كل تغيير حالة. أما الحالات التي هي أكثر، لا أقول ذات حالة، فهذه هي الطريقة.
إجراءات الدخول والخروج
هدف نمط الحالة هو تغليف كل سلوك وبيانات حالة واحدة في صنف واحد. وقد بلغنا جزءاً من الطريق، لكن ما زالت لدينا بعض الأمور المعلّقة.
عندما تغيّر البطلة حالتها، فإننا نبدّل صورتها أيضاً. وحالياً، تملك تلك الشيفرة الحالة التي هي منتقلة منها. فحين تنتقل من الانخفاض إلى الوقوف، تقوم حالة الانخفاض بتغيير صورتها:
^code enter-standing-before
وما نريده حقاً هو أن تتحكم كل حالة برسومها الخاصة. يمكننا معالجة ذلك بمنح الحالة إجراء دخول (entry action):
^code standing-with-enter
والعودة إلى Heroine، فإننا نعدّل شيفرة معالجة تغيّر الحالة كي تستدعي ذلك على
الحالة الجديدة:
^code change-state
يتيح لنا ذلك تبسيط شيفرة الانخفاض لتصبح:
^code enter-standing
كل ما تفعله هو الانتقال إلى الوقوف، وحالة الوقوف هي التي تتولى الرسوم. الآن أصبحت حالاتنا مغلَّفة فعلاً. ومن الأشياء اللطيفة بشكل خاص في إجراءات الدخول أنها تُنفَّذ عندما تدخل الحالة بغضّ النظر عن الحالة التيأتي منها.
معظم رسوم الحالات في العالم الحقيقي لها انتقالات متعددة إلى الحالة نفسها. فمثلاً، ستنتهي بطلتنا واقفة أيضاً بعد أن تهبط من قفزة أو من غطس. وهذا يعني أننا سنضطر إلى تكرار بعض الشيفرة في كل موضع يحدث فيه ذلك الانتقال. وتوفر لنا إجراءات الدخول مكاناً لتجميع ذلك.
يمكننا بالطبع توسيع ذلك ليدعم إجراء خروج (exit action). وهو ليس سوى دالة نستدعيها على الحالة التي هي مغادرة إياها، قبل أن ننتقل إلى الحالة الجديدة مباشرة.
ما المشكلة؟
أمضيت كل هذا الوقت أبيعك آلات الحالات المنتهية، والآن سأنزع السجادة من تحت قدميك. كل ما قلته حتى الآن صحيح، وآلات الحالات المنتهية تناسب بعض المشكلات بشكل جيد. لكن أعظم مزاياها هي أيضاً أعظم عيوبها.
تساعدك آلات الحالات على فكّ تشابك الشيفرة المعقّدة عبر فرض بنية مقيّدة جداً عليها. فكل ما لديك هو مجموعة حالات ثابتة، وحالة حالية واحدة، وبعض الانتقالات المكتوبة يدوياً بصرامة.
إذا حاولت استخدام آلة حالات لأمر أعقد مثل ذكاء الاصطناعي في الألعاب، فستصطدم بوجهك بحدود ذلك النموذج. ولحسن الحظ، وجد أسلافنا طرقاً لتفادي بعض تلك العوائق. وسأختم هذا الفصل بأن أمشي معك في بعض منها.
آلات حالات متزامنة
قررنا منح بطلتنا القدرة على حمل مسدس. فحين تحمل السلاح، ما زالت قادرة على فعل كل ما كانت قادرة عليه سابقاً: الجري، والقفز، والانخفاض، وغيرها. لكنها تحتاج أيضاً أن تكون قادرة على إطلاق النار على سلاحها أثناء ذلك.
إن أردنا الالتزام بقيود آلة الحالات المنتهية، فعينا أن نضاعف عدد الحالات التي لدينا. فلكل حالة قائمة، سنحتاج إلى حالة أخرى تؤدّي الشيء نفسه وهي مسلّحة: واقفة، واقفة مع مسدس، قافزة، قافزة مع مسدس، وقد فهمت الفكرة.
أضف سلاحاً أو اثنين آخرين، فيتضخّم عدد الحالات بشكل توافقي. لا يقتصر الأمر على كونه عدداً هائلاً من الحالات، بل هو أيضاً قدر هائل من التكرار: فحالات عدم التسليح وحالات التسليح متطابقة تقريباً باستثناء القليل من الشيفرة التي تتعامل مع إطلاق النار.
المشكلة أننا حشرنا قطعتين من الحالة — ما تفعله وما تحمله — في آلة واحدة. ولنمذج كل التوليفات الممكنة، سنحتاج إلى حالة لكل زوج. والحل واضح: استخدم آلتي حالات منفصلتين.
نُبقي آلة الحالات الأصلية الخاصة بما تفعله ونتركها وشأنها. ثم نعرّف آلة حالات
منفصلة لما تحمله. وسيكون لدى Heroine مرجعا حالة، واحد لكل منهما، هكذا:
^code two-states
عندما تفوّض البطلة المدخلات إلى الحالات، فإنها تمرّرها إلى كليهما:
^code handle-two-inputs
يمكن لكل آلة حالات بعد ذلك أن تستجيب للمدخلات، وتُطلق سلوكاً، وتغيّر حالتها بشكل مستقل عن الآلة الأخرى. وحين تكون مجموعتا الحالات غير مرتبطتين إلى حد بعيد، فإن هذا ينجح جيداً.
في الواقع، ستجد بضعة حالات تتفاعل فيها الحالات فعلاً. فمثلاً، ربما لا تستطيع
إطلاق النار أثناء القفز، أو ربما لا تستطيع تنفيذ هجوم غطس إذا كانت مسلّحة.
ولمعالجة ذلك، من المرجّح أنك ستكتفي بإجراء بعض اختبارات if الخام على حالة
الآلة الأخرى لتنسيقهما. وليست هذه أنيق الحلول، لكنها تُنجز المهمة.
آلات الحالات الهرمية
بعد أن نُعمّق سلوك بطلتنا أكثر، فستكون لديها على الأرجح حفنة من الحالات المتشابهة. فمثلاً، قد تكون لها حالات الوقوف والمشي والجري والانزلاق. وفي أيّ منها، يسبّب الضغط على B القفز والضغط إلى أسفل الانخفاض.
مع تنفيذ بسيط لآلة الحالات، علينا أن نكرّر تلك الشيفرة في كل واحدة من تلك الحالات. وكان الأمر أفضل لو استطعنا تنفيذها مرة واحدة وإعادة استخدامها في جميع الحالات.
ولو كانت هذه مجرد شيفرة كينونية بدل آلة حالات، فإن إحدى طرق مشاركة الشيفرة بين تلك الحالات هي استخدام الوراثة. فيمكننا تعريف صنف لحالة «على الأرض» يتعامل مع القفز والانخفاض. ثم ترث منه حالات الوقوف والمشي والجري والانزلاق وتضيف سلوكها الخاص.
يتبيّن أن هذا بنية شائعة تُسمى آلة الحالات الهرمية. فيمكن أن تكون للحالة حالة عليا (superstate) (فتصبح هي نفسها حالة فرعية). وعندما يأتي حدث، فإن الحالة الفرعية إن لم تعالجه تصعد في سلسلة الحالات العليا. وبعبارة أخرى، تعمل تماماً مثل تجاوز الدوال الموروثة.
وفي الحقيقة، إن كنا نستخدم نمط الحالة لتنفيذ آلة حالاتنا المنتهية، يمكننا استخدام وراثة الأصناف لتنفيذ التسلسل الهرمي. عرّف صنف أساس للحالة العليا:
^code on-ground
ثم ترث كل حالة فرعية منه:
^code duck-on-ground
هذه بالطبع ليست الطريقة الوحيدة لتنفيذ التسلسل الهرمي. فإذا لم تكن تستخدم نمط الحالة لدى «الرباعية» فلن ينفع هذا. وبدلاً من ذلك، يمكنك نمذجة سلسلة الحالات العليا للحالة الحالية صراحةً باستخدام مكدّس (stack) من الحالات بدلاً من حالة واحدة في الصنف الرئيسي.
الحالة الحالية هي الموجودة في قمة المكدّس، وتحتها حالتها العليا المباشرة، ثم حالة تلك الحالة العليا وهكذا. وحين توزّع سلوكاً خاصاً بحالة ما، تبدأ من قمة المكدّس وتمشي نزولاً حتى تعالجه إحدى الحالات. (وإذا لم تفعل ذلك إحداها، فتجاهله.)
المآلات الدفعية (Pushdown Automata)
هناك توسيع شائع آخر لآلات الحالات المنتهية يستخدم أيضاً مكدّساً من الحالات. والغريب أن هذا المكدّس يمثّل شيئاً مختلفاً تماماً، ويُستخدم لحل مشكلة مختلفة.
المشكلة أن آلات الحالات المنتهية لا تملك أي مفهوم لـالتاريخ (history). فأنت تعرف الحالة التي أنت فيها، لكن ليس لديك أي ذاكرة للحالة التي كنت فيها. ولا توجد طريقة سهلة للعودة إلى حالة سابقة.
إليك مثالاً: في وقت سابق، سمحنا لبطلتنا الشجاعة أن تسلّح نفسها بالأسلحة
المحتملة كلها. وحين تُطلق نارها، نحتاج إلى حالة جديدة تشغّل حركة إطلاق النار
وتُنشئ الطلقة وأي تأثيرات بصرية. فنُنشئ على عجل صنف FiringState ونجعل
كل الحالات التي يمكنها إطلاق النار تنتقل إليه عند
ضغط زر الإطلاق.
الجزء الصعب هو الحالة التي تنتقل إليها بعد إطلاق النار. فهي تستطيع أن تُطلق طلقة أثناء الوقوف أو الجري أو القفز أو الانخفاض. وعندما يكتمل تسلسل إطلاق النار، ينبغي أن تنتقل عائدةً إلى ما كانت تفعله قبل ذلك.
وإن كنا ملتزمين بآلة حالات خالصة، فقد نَسِينا بالفعل الحالة التي كانت فيها. ولتتبّع ذلك، علينا أن نعرّف سيلاً من الحالات المتشابهة تقريباً — إطلاق النار أثناء الوقوف، وأثناء الجري، وأثناء القفز، وهكذا — لمجرد أن يحوي كل واحد منها انتقالاً مكتوباً يدوياً يعود إلى الحالة الصحيحة عند انتهائه.
وما نودّه حقاً هو طريقة تخزّن الحالة التي كانت فيها قبل إطلاق النار ثم تستدعيها لاحقاً. وهنا تعود نظرية الأوتوماتا للمساعدة. فبنية البيانات ذات الصلة تُسمى المآلة الدفعية (pushdown automaton).
في حين تملك آلة الحالات المنتهية مؤشراً واحداً إلى حالة، تملك المآلة الدفعية مكدّساً منها. وفي آلة الحالات المنتهية، فإن الانتقال إلى حالة جديدة يستبدل السابقة. وتتيح لك المآلة الدفعية أن تفعل ذلك، لكنها تمنحك أيضاً عمليتين إضافيتين:
-
يمكنك دفع (push) حالة جديدة إلى المكدّس. فحالة «الحالية» هي دائماً ما يوجد في قمة المكدّس، وهذا ينتقل بك إلى الحالة الجديدة. لكنه يترك الحالة السابقة في المكدّس مباشرة تحتها بدلاً من التخلص منها.
-
يمكنك سحب (pop) الحالة الموجودة في القمة من المكدّس. فتُستبعد تلك الحالة، وتصبح الحالة التي تحتها هي الحالة الحالية الجديدة.
هذا هو بالضبط ما نحتاجه لإطلاق النار. فنحن ننشئ حالة إطلاق نار واحدة. وحين يُضغط زر الإطلاق أثناء وجودنا في أي حالة أخرى، فإننا ندفع حالة إطلاق النار إلى المكدّس. وعندما تنتهي حركة إطلاق النار، فإننا نسحب تلك الحالة من المكدّس، وتنتقل المآلة الدفعية تلقائياً بك عائداً إلى الحالة التي كنت فيها قبل ذلك.
إذن، ما مدى نفعها؟
حتى مع تلك التوسيعات الشائعة لآلات الحالات، فإنها ما زالت محدودة إلى حد ما. فالاتجاه في ذكاء الاصطناعي للألعاب هذه الأيام يميل نحو أشياء أكثر إثارة مثل أشجار السلوك و*أنظمة التخطيط*. وإن كان ما يهمك هو الذكاء الاصطناعي المعقّد، فقد كلّف هذا الفصل كل ما كَلّفه فحسب من إشعار شهيتك. وستحتاج إلى قراءة كتب أخرى لإشباعها.
هذا لا يعني أن آلات الحالات المنتهية والمآلات الدفعية والأنظمة البسيطة الأخرى غير مفيدة. فهي أداة نمذجة جيدة لبعض أنواع المشكلات. وتكون آلات الحالات المنتهية مفيدة حين:
-
لديك كيان يتغيّر سلوكه بناءً على بعض الحالات الداخلية.
-
يمكن تقسيم تلك الحالة بقسوة إلى واحد من عدد قليل نسبياً من الخيارات المتمايزة.
-
يستجيب الكيان لسلسلة من المدخلات أو الأحداث مع مرور الوقت.
في الألعاب، هي معروفة أكثر من استخدامها في الذكاء الاصطناعي، لكنها شائعة أيضاً في تنفيذات معالجة مدخلات المستخدم، والتنقّل بين شاشات القوائم، وتحليل النصوص، وبروتوكولات الشبكة، والسلوك اللامتزامن الأخر.