تبدأ كل دروس تطوير الألعاب بنفس الطريقة: مستطيلات ملونة تنزلق عبر لوحة رمادية. هذا أمر جيد لتعلم القواعد البرمجية (syntax)، لكنه لا يعلمك شيئاً عن كيفية تنفس محرك الألعاب فعلياً. أردت الخروج من عالم المستطيلات. أردت بناء شيء يشبه الألعاب الحقيقية—لعبة استكشاف سراديب (dungeon crawler) من منظور علوي مع حركة، وقتال، وواجهة مستخدم (HUD)، وصوت.
التزمت باستخدام Phaser v4 ووضعت لنفسي قاعدة صارمة واحدة: صفر من الأصول الخارجية (external assets). لا ملفات صور، لا مقاطع صوتية، ولا أدوات بناء مثل Webpack أو Vite. كان يجب أن يعيش المشروع بأكمله داخل ملف HTML واحد مكتوب بلغة JavaScript بسيطة. لم يكن هذا القيد من أجل التقليلية (minimalism) لذاتها، بل كان لإزالة كل عذر وكل "صندوق أسود". عندما لا تستطيع تحميل حزمة رسومات (sprite pack) لتغطية فجوة في معرفتك، فإنك تُجبر على تعلم كيفية إدارة المحرك للقوام (textures)، والرسوم المتحركة (animations)، والصوت، والحالة (state) من الداخل.
رسم العالم من خلال الكود
في مشروع Phaser عادي، تقوم باستدعاء this.load.image() داخل دالة preload وتوجه المحرك نحو ملف PNG. بدون هذا الخيار، تلجأ إلى كائن Graphics. تقوم بإنشائه، وترسم أشكالاً أولية—مستطيلات لبلاطات الأرضية، وخطوطاً أكثر سمكاً للجدران، وربما دائرة لرمز اللاعب—ثم تستدعي generateTexture. تقوم هذه الطريقة بالتقاط ذاكرة التخزين المؤقت للرسومات (graphics buffer) وتسجيلها في مدير القوام (texture manager) الخاص بـ Phaser تحت مفتاح تختاره.
منذ تلك اللحظة، يعامل المحرك تلك الصورة النقطية (bitmap) المُنشأة تماماً مثل ملف صورة محمل. يمكنك تعيينها لخرائط البلاط (tilemaps)، أو تقسيمها إلى رسومات (sprites)، أو تلوينها. بالنسبة للعبة استكشاف السراديب، كان هذا يعني أنه يمكنني إنشاء شبكة أرضية إجرائياً (procedurally)، ووضع قطع الجدران، وتكرار تجربة لوحة الألوان دون مغادرة محرر الكود الخاص بي أبداً. الدرس العملي هو أن القوام (texture) ليس سوى قطعة من بيانات bitmap موجودة في الذاكرة. لا يهتم Phaser ما إذا كانت قد وصلت عبر طلب HTTP أو عبر استدعاء Graphics مكتوب يدوياً.
هذا النهج يجعلك تفكر أيضاً بشكل مدروس في ترتيب الرسم (draw order) والتجميع (batching). عندما تأتي كل جدار وبلاطة أرضية من نفس عائلة القوام المُنشأة، تبدأ في الانتباه إلى كيفية تجميع Phaser لاستدعاءات التصيير (render calls). ستلاحظ الفرق بين طبقة tilemap ثابتة وخريطة رسومات (spritemap) لكائنات فردية لأنك تقرر يدوياً ما الذي يستحق الوجود كنسخة قوام (texture instance).
التحريك بدون Spritesheets
المربعات الثابتة تصبح مملة بسرعة، لكن نظام التحريك في Phaser يتوقع وجود spritesheet—عادة ما يكون ملف PNG واحد مع إطارات مرتبة في شبكة. قمت بمحاكاة ذلك الشريط باستخدام عنصر HTML canvas خارج الشاشة. لكل إطار من الرسوم المتحركة، قمت بمسح الـ canvas ورسم وضعية جديدة: أرجحة سيف بسيطة، أو دورة مشي من خطوتين، أو حركة خمول للعدو. بمجرد اكتمال الشريط، قمت بتسجيله في Phaser كـ spritesheet، مع تحديد عرض وارتفاع الإطار حتى يعرف المحرك أين تنتهي الوضعية الأولى وأين تبدأ التالية.
القيام بذلك يدوياً يكشف بالضبط عما هي الرسوم المتحركة تحت السطح: مجموعة من حدود الإطارات على قوام مشترك. يطلب مكون التحريك في Phaser إطار البداية، وإطار النهاية، ومعدل الإطارات (frame rate). ثم يقوم بتحريك مؤشر عبر تلك الشرائح المستطيلة مع كل نبضة (tick) من ساعة اللعبة. ستتوقف عن التفكير في spritesheets كأصول سحرية ينتجها الفنانون وتبدأ في رؤيتها كرياضيات إحداثيات. هذه الرؤية لا تقدر بثمن لاحقاً عندما تقوم بتقسيم فن حقيقي أو تصحيح أخطاء سبب تشغيل الرسوم المتحركة للإطارات بترتيب خاطئ.
صوت من لا شيء
كانت ملفات الصوت محظورة، لذا استخدمت Web Audio API مباشرة. بضعة أسطر من JavaScript يمكنها إنشاء عقدة تذبذب (oscillator node)، وضبطها على موجة مربعة أو جيبية، وتمريرها عبر عقدة كسب (gain node)، وجدولة دفعة قصيرة من الصوت. كتبت أدوات مساعدة صغيرة للأحداث الشائعة: صفير منخفض التردد لخطوات الأقدام، وزقزقة متصاعدة لالتقاط الغنائم، ونغمة موجة مربعة حادة لتلقي الضرر.
هذه الأصوات الاصطناعية هي مجرد بدائل (placeholders) حسب التصميم، لكنها تعطي اللعبة استجابة ميكانيكية فورية. يمكنك الشعور ما إذا كان التوقيت صحيحاً قبل الالتزام بالتسجيل أو البحث عن صوت حقيقي. المكافأة الحقيقية تكمن في البنية البرمجية. نظرًا لأنك قمت بتغليف مشغل الصوت في دالة بسيطة، فإن استبدال الصفير الاصطناعي بمخزن صوت محمل (sound buffer) لاحقاً يتطلب تغييراً في سطر واحد فقط. يظل باقي اللعبة—حدث التصادم، وميض واجهة المستخدم، وزيادة النتيجة—دون تغيير. أنت تصمم النموذج الأولي للشعور أولاً، ثم ترفع مستوى الجودة لاحقاً.
إدارة عوالم متعددة
تحتاج اللعبة الحقيقية إلى أكثر من شاشة واحدة، لذا قمت بتقسيم المشروع إلى مشاهد Phaser منفصلة: Menu، Game، UI، وPause. يعمل مشهد UI بالتوازي مع مشهد Game، حيث يتم إطلاقهما في وقت واحد بحيث يعيش شريط الصحة وعداد النقاط في بيئة معزولة خاصة بهما بينما تستمر عمليات استكشاف الزنزانات (dungeon crawls) في الأسفل. يتواصل المشهدان بدقة من خلال الأحداث. عندما يتلقى اللاعب ضررًا، يصدر مشهد Game حدث تغيير. يستمع مشهد UI لهذا الحدث ويقوم بتحديث كائنات النصوص الخاصة به. لا يقوم مشهد Game باستيراد UI، أو استدعاء وظائفه، أو حتى التحقق من وجوده. إنه ببساطة يرسل البيانات إلى الفراغ. هذا الفصل (decoupling) يعني أنه يمكنك نزع الـ HUD للاختبار، أو استبداله بالكامل، دون المساس بحلقة اللعبة الأساسية (core game loop).
بالنسبة للإيقاف المؤقت، استخدمت مشهد Pause متراكبًا يوضع فوق مشهد Game. والأهم من ذلك، أن استدعاء scene.pause() على مشهد Game يؤدي فعليًا إلى تجميد عالم الفيزياء وإيقاف المؤقتات. يتوقف مشهد Game عن التحديث، لكن مشهد Pause يظل نشطًا لعرض القائمة وانتظار إشارة استئناف التشغيل. إذا كنت قد أدرت حالات الإيقاف المؤقت سابقًا باستخدام علامة منطقية (boolean flag) داخل حلقة تحديث واحدة ضخمة، فستشعر وكأنك اكتشفت مفتاح إضاءة. يوفر لك المحرك دورة حياة حقيقية للإيقاف المؤقت بدلاً من إجبارك على حشو الكود الخاص بك بحواجز مثل if (isPaused) return.
دروس قاسية من أخطاء برمجية حقيقية
العمل بهذا القرب من العتاد (close to the metal) كشف عن عادتين كنت بحاجة إلى تغييرهما.
أولاً، حاولت استخدام طريقة في Phaser v4 بدت عامة ولكنها لم تكن جزءًا من الـ API الموثق. تغيرت هذه الطريقة بين الإصدارات وتسببت في تعطل البناء (build). قمت بإعادة الهيكلة (refactored) لاستخدام getChildren()، وهي طريقة عامة مستقرة وموثقة، واختفى عدم الاستقرار. الدرس واضح: إذا لم تكن الطريقة موجودة في التوثيق الرسمي، فلا تبنِ لعبتك عليها. الـ APIs الداخلية هي داخلية لسبب ما. التزم بالمساحة السطحية العامة (public surface area) وسينجو مشروعك من تحديثات المحرك.
ثانيًا، تعلمت ألا أثق في الأحداث (events) لكل شيء. تعمل استدعاءات الأحداث (event callbacks) بشكل مثالي للتفاعلات منخفضة المخاطر مثل التقاط عملة أو فتح صندوق. ولكن بالنسبة لانتقالات الحالة الحرجة — خاصة نهاية اللعبة (Game Over) — أضفت فحصًا إضافيًا داخل حلقة التحديث الرئيسية. يمكن أن تخطئ الأحداث في العمل إذا تمت إزالة المستمع، أو توقف المشهد في جزء من الثانية غير مناسب، أو حدثت حالة تسابق (race condition) بين الإرسال والمعالجة. من خلال التحقق من صحة اللاعب مباشرة في حلقة التحديث وفرض حالة نهاية اللعبة إذا وصلت الصحة إلى الصفر، ضمنت عدم تعليق اللعبة في حالة ضياع (limbo state) إذا فشل الحدث في العمل. لا تزال الأحداث تتعامل مع التأثيرات الثانوية — مثل اهتزاز الشاشة، والإشارات الصوتية، وإرسال النقاط — ولكن المنطق المرجعي (authoritative logic) يعيش حيث تعيش ساعة اللعبة.
لماذا يجب عليك تجربة هذا
إذا كنت تتعلم تطوير الألعاب، فافرض هذا القيد بالضبط على مشروعك القادم: لا أصول خارجية، ملف HTML واحد فقط. قد يبدو الأمر تقييديًا، لكنه يزيل كل الأعذار. لست بحاجة إلى تكوين أداة تجميع (bundler)، أو الصراع مع أخطاء CORS في ملفات الصوت المحلية، أو قضاء فترة ما بعد الظهر في تنسيق حزم الأصول المجانية. أنت تكتب الكود، وتحدث المتصفح، وترى النتائج.
والأهم من ذلك، ستفهم سبب تصرف المحرك بالطريقة التي يتصرف بها. ستعرف كيف تدخل الأنسجة (texture) إلى الـ GPU لأنك استدعيت generateTexture. ستعرف كيف يتم فهرسة إطارات الرسوم المتحركة لأنك سجلت الحدود يدويًا. ستعرف كيف يصل الصوت إلى السماعات لأنك قمت بتوصيل المذبذب (oscillator). تنتقل هذه المعرفة مباشرة إلى المشاريع الأكبر التي تستخدم أصولًا خارجية، لأن الميكانيكا الأساسية لا تتغير أبدًا — المحرك
