أنماط برمجة الألعاب › الأمر (command) 3 / 26 السابق التالي

الأمر

^title Command ^section Design Patterns Revisited

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

ولهذا النمط الرائع، لدى الرباعية وصف غامض على نحو متوقّع:

غلّف طلباً في كائن، فتسمح بذلك للمستخدمين بأن يعدّلوا معاملات المستقبِلين بطلبات مختلفة، وأن يضعوا الطلبات في طابور أو يسجّلوها، وأن يوفّروا عمليات قابلة للتراجع.

أعتقد أننا جميعاً يمكننا الاتفاق على أن هذه جملة سيئة. ففي المقام الأول، تشوّه الاستعارة التي تحاول هذه الجملة بناءها. فخارج عالم البرمجيات الغريب حيث يمكن للكلمات أن تعني أي شيء، يكون «المستقبِل» (client) شخصاً — شخصاً تتعامل معه في عمل ما. وآخر مرة تحقّقت فيها، لم يكن البشر قابلين لـ«تحديد المعاملات».

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

الأمر هو استدعاء دالة مُجسَّد.

وبالطبع، فإن «اللاذعة» تعني غالباً «مُختصرة إلى حدٍّ لا يُفهم»، فربما لا يكون هذا تحسّناً كبيراً. دعني أفكّك ذلك قليلاً. فكلمة «التجسيد» (reify)، إن لم تكن قد سمعتها من قبل، تعني «جعل الشيء حقيقياً». وكلمة أخرى لتجسيد شيء هي جعله من «الطبقة الأولى».

كلتا الكلمتين تعنيان أخذ مفهوم ما وتحويله إلى قطعة من البيانات — أي كائن — يمكنك وضعه في متغيّر أو تمريره إلى دالة وما إلى ذلك. فحين أقول إن نمط الأمر هو «استدعاء دالة مُجسَّد»، فإنني أعني أنه استدعاء دالة مغلَّف في كائن.

يبدو ذلك أشبه كثيراً بـ«استدعاء راجع» (callback) أو «دالة من الطبقة الأولى» أو «مؤشر دالة» أو «إغلاق» (closure)، بحسب اللغة التي تأتي منها. وفعلاً، كل هذه الأشياء في الميدان نفسه. وتقول الرباعية لاحقاً:

الأوامر بديل موجه للكائنات للاستدعاءات الراجعة.

ولكان ذلك عنواناً أفضل للنمط مما اختاروه.

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

ضبط الإدخال

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

ذراع تحكّم، فيه A مرتبط بـ swapWeapon()، وB مرتبط بـ lurch()، وX مرتبط بـ jump()، وY مرتبط بـ fireGun().

ويبدو التنفيذ البسيط جداً هكذا:

^code handle-input

تُستدعى هذه الدالة عادةً مرةً في كل إطار (frame) بواسطة حلقة اللعبة (Game Loop)، وأنا واثق أنك تستطيع تخيّل ما تفعله. وتعمل هذه الشيفرة إن كنا مستعدين لربط إدخالات المستخدم بإجراءات اللعبة بشكل ثابت، لكن الألعاب كثيرة تتيح للمستخدم ضبط كيفية ربط أزراره.

ولدعم ذلك، نحتاج إلى تحويل استدعاءات jump() وfireGun() المباشرة إلى شيء يمكننا استبداله. وكلمة «استبدال» تشبه إلى حدٍّ كبير إسناد قيمة إلى متغيّر، لذا نحتاج إلى كائن نستخدمه لتمثيل إجراء من إجراءات اللعبة. وها هو نمط الأمر يدخل المشهد.

نعرّف صنفاً أساسياً يمثّل أمر لعبة قابلاً للتشغيل:

^code command

ثمّ ننشئ أصنافاً فرعية لكل إجراء من إجراءات اللعبة المختلفة:

^code command-classes

وفي معالج الإدخال لدينا، نخزّن مؤشراً إلى أمر لكل زر:

^code input-handler-class

أما الآن فقد صار معالجة الإدخال مجرد تفويض إلى تلك الأوامر:

^code handle-input-commands

حيث كان كل إدخال يستدعي دالة مباشرة، صارت الآن هناك طبقة من التوجيه:

ذراع تحكّم، كل زر فيه مرتبط بمتغيّر «button_» مقابل، يرتبط هذا المتغيّر بدوره بدالة.

هذه هي خلاصة نمط الأمر. فإذا رأيتَ فائدته بسهولة، فاعتبر بقية هذا الفصل مكافأة.

توجيهات للممثلين

أصناف الأوامر التي عرّفناها للتو تعمل في المثال السابق، لكنها محدودة إلى حدٍّ كبير. المشكلة أنها تفترض وجود دوال عليا مثل jump() وfireGun() تعرف ضمناً كيف تجد صورة اللاعب وتجعله يرقص كدمية محترفة. وهذا الترابط الضمني يحدّ من فائدة تلك الأوامر. فـ JumpCommand لا تستطيع جعل أي شيء يقفز إلا اللاعب. فلنخفّف هذا القيد. وبدلاً من استدعاء دوال تجد الكائن المُأمَر بنفسها، سنقوم بـتمرير الكائن الذي نريد أن نصدر إليه الأمر:

^code actor-command

هنا GameActor هي صنف «كائن اللعبة» الذي يمثّل شخصية في عالم اللعبة. نمرّره إلى execute() ليتمكّن الأمر المشتق من استدعاء الدوال على ممثل من اختيارنا، كهذا:

^code jump-actor

الآن يمكننا استخدام هذا الصنف الواحد لجعل أي شخصية في اللعبة تقفز هنا وهناك. وينقصنا فقط قطعة بين معالج الإدخال والأمر، تأخذ الأمر وتستدعيه على الكائن الصحيح. أولاً، نغيّر handleInput() لتُعيد الأوامر:

^code handle-input-return

ولن تستطيع تنفيذ الأمر فوراً لأنها لا تعرف أي ممثل تُمرّره. وهنا نستفيد من كون الأمر استدعاءً مُجسَّداً — يمكننا تأخير وقت تنفيذ الاستدعاء.

ثمّ نحتاج إلى شيفرة تأخذ ذلك الأمر وتنفّذه على الممثّل الذي يمثّل اللاعب. شيء كهذا:

^code call-actor-command

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

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

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

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

أنبوب يربط AI بـ Actor.

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

التراجع والإعادة

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

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

ولحسن الحظ، فنحن نستخدم الأوامر أصلاً لتجريد معالجة الإدخال، بحيث تكون كل حركة يقوم بها اللاعب مغلَّفة فيها أصلاً. فتحريك وحدة، مثلاً، قد يبدو هكذا:

^code move-unit

لاحظ أن هذا مختلف قليلاً عن أوامرنا السابقة. ففي المثال الأخير، أردنا تجريد الأمر من الممثّل الذي يعدّله. أما هنا، فنريد على وجه التحديد ربطه بالوحدة التي تتحرّك.

فنسخة من هذا الأمر ليست عملية عامة «حرّك شيئاً ما» يمكنك استخدامها في سياقات عديدة؛ بل هي حركة ملموسة بعينها ضمن تسلسل أدوار اللعبة.

وهذا يبرز تنوّعاً في كيفية تنفيذ نمط الأمر. ففي بعض الحالات، مثل مثالينا الأولين، يكون الأمر كائناً قابلاً لإعادة الاستخدام يمثّل شيئاً يمكن فعله. فمعالج الإدخال السابق لديه كان يحتفظ بكائن أمر واحد ويستدعي دالة execute() فيه كلما ضغط الزر المناسب.

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

^code get-move

وحقيقة أن الأوامر تُستخدم مرة واحدة فقط ستكون في صالحنا بعد قليل.

ولجعل الأوامر قابلة للتراجع، نعرّف عملية أخرى يجب على كل صنف أمر أن ينفّذها:

^code undo-command

وتعكس دالة undo() حالة اللعبة التي غيّرتها دالة execute() المقابلة. وهذا هو أمر تحريك الوحدة السابق مع دعم التراجع:

^code undo-move-unit

لاحظ أننا أضفنا حالةً أكثر إلى الصنف. فحين تتحرّك وحدة، فإنها تنسى أين كانت من قبل. وإذا أردنا القدرة على التراجع عن تلك الحركة، فعلينا أن نتذكّر موقع الوحدة السابق بأن أنفسنا، وهو ما تفعله xBefore_ وyBefore_.

ولكي يتمكّن اللاعب من التراجع عن حركة، نحتفظ بآخر أمر نفّذه. وحين يضغط على Control-Z، نستدعي دالة undo() لذلك الأمر. (وإذا كان قد تراجع بالفعل، تصبح «إعادة» وننفّذ الأمر من جديد.)

ودعم مستويات متعددة من التراجع ليس أصعب بكثير. فبدلاً من تذكّر الأمر الأخير، نحتفظ بقائمة أوامر ومرجعاً إلى الأمر «الحالي».

وحين ينفّذ اللاعب أمراً، نضيفه إلى القائمة ونجعل «الحالي» يشير إليه.

رزمة أوامر مرتبة من الأقدم إلى الأحدث. سهم «current» يشير إلى أمر ما، وسهم «undo» يشير إلى الأمر السابق له، وسهم «redo» يشير إلى الأمر التالي.

وحين يختار اللاعب «تراجع»، نتراجع عن الأمر الحالي ونحرّك المؤشّر الحالي إلى الخلف. وحين يختار «إعادة»، نُقدّم المؤشّر ثم ننفّذ ذلك الأمر. وإذا اختار أمراً جديداً بعد التراجع عن بعض الأوامر، فإن كل ما في القائمة بعد الأمر الحالي يُطرح بعيداً.

أول مرة نفّذت هذا في محرّك مستويات، شعرت بأنني عبقري. كنت مندهشاً من بساطته ومن مدى نجاحه.

ويلزمك قدرٌ من الانضباط للتأكد من أن كل تعديل على البيانات يمرّ عبر أمر، لكن بمجرد أن تفعل ذلك يصبح الباقي سهلاً.

أنيق أم معطوب؟

قلتُ في وقت سابق إن الأوامر تشبه الدوال من الطبقة الأولى أو الإغلاق (closure)، لكن كل الأمثلة التي عرضتها هنا استخدمت تعريفات أصناف.

وإن كنت على معرفة بالبرمجة الوظيفية، فأنت على الأرجح تتساءل أين الدوال.

كتبت الأمثلة بهذه الطريقة لأن دعم C++ للدوال من الطبقة الأولى محدود جداً.

فمؤشّرات الدوال بلا حالة، والمُغلفة الدالية (functors) غريبة وما زالت تتطلب تعريف صنف، وأما lambdas في C++11 فمعالجتها صعبة بسبب إدارة الذاكرة اليدوية.

وهذا لا يعني أنك لا ينبغي أن تستخدم دوالاً لنمط الأمر في اللغات الأخرى. فإذا كانت لديك لغة ذات إغلاقات حقيقية، فاستخدمها بكل سرور!

ففي بعض الطرق، يكون نمط الأمر وسيلة لتقليد الإغلاقات في اللغات التي لا تملكها.

ولنأخذ مثالاً: لو كنا نبني لعبة في JavaScript، لأمكننا إنشاء أمر تحريك وحدة هكذا:

:::javascript
function makeMoveUnitCommand(unit, x, y) {
  // This function here is the command object:
  return function() {
    unit.moveTo(x, y);
  }
}

ويمكننا أيضاً إضافة دعم للتراجع باستخدام زوج من الإغلاقات:

:::javascript
function makeMoveUnitCommand(unit, x, y) {
  var xBefore, yBefore;
  return {
    execute: function() {
      xBefore = unit.x();
      yBefore = unit.y();
      unit.moveTo(x, y);
    },
    undo: function() {
      unit.moveTo(xBefore, yBefore);
    }
  };
}

وإذا كنت مرتاحاً مع الأسلوب الوظيفي، فإن هذه الطريقة في التعامل طبيعية. وإن لم تكن كذلك، فأرجو أن يكون هذا الفصل قد ساعدك قليلاً.

بالنسبة إليّ، فإن فائدة نمط الأمر تُظهر بوضوح مدى فاعلية النموذج البرمجي الوظيفي في ولتسهيل تنفيذ تلك الأصناف، من المفيد غالباً أن عرّف صنفاً أساسياً ملموساً فيه مجموعة من دوال عليا مريحة يمكن للأوامر المشتقة أن تركّبها لتحديد سلوكها. وهذا يحوّل دالة execute() الرئيسية في الأمر إلى صندوق صنف فرعي (Subclass Sandbox).

انظر أيضاً

  • قد ينتهي بك المطاف إلى أصناف أوامر كثيرة.

  • في أمثلتنا، اخترنا صراحةً الممثل الذي سيتعامل مع الأمر. وفي بعض الحالات، خاصةً عندما يكون نموذج الكائنات عندك هرمياً، فقد لا يكون الأمر بهذه البساطة. فقد يستجيب كائن ما للأمر، أو قد يقرّر أن يحوّله إلى كائن تابع له. وإذا فعلت ذلك، فقد حصلت على سلسلة المسؤولية (Chain of Responsibility).

  • بعض الأوامر عبارة عن كتل سلوك خالصة بلا حالة، مثل JumpCommand في المثال الأول. وفي حالات كهذه، فإن وجود أكثر من نسخة من ذلك الصنف يهدر الذاكرة لأن جميع النسخ متكافئة. ونمط الوزن الخفيف (Flyweight) يعالج ذلك.