بنية Player

لماذا يعمل Romergo Player باستخدام DOM العادي

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

يتكون مشهد الرواية المرئية المعتاد من مجموعة واضحة نسبيًا من العناصر:

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

بحثت طويلًا عن محرك واخترت المتصفح

جربت بناء المشهد باستخدام Godot وPixiJS وThree.js. عملت جميعها بدرجات متفاوتة من النجاح، ولم تكن المشكلة في المحركات نفسها. لكنها أضافت، بالنسبة إلى طريقتي في إنشاء quests، طبقة أخرى كان عليّ التعامل معها بصورة مستقلة.

لم أكن أحتاج إلى شاشة لعبة جاهزة فقط. كان من المهم أن أرى أي تغيير فورًا في Builder، وأن أشغّل مشهدًا واحدًا من دون بناء اللعبة كاملة، وأن أحدد العناصر وأحركها مباشرة في preview، ثم أحصل على السلوك نفسه في Player المنشور.

في النهاية كان الحل الأكثر فاعلية هو الأبسط: DOM العادي في المتصفح.

تظل الخلفية والشخصيات والحوار طبقات مألوفة في المتصفح. ويُبنى معظم التكوين والمؤثرات باستخدام CSS. ويُستخدم Canvas 2D لعناصر بصرية محددة، بينما يُضاف WebGL canvas حيث تكون انتقالات shaders بين الصور ضرورية فعلًا.

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

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

مشهد بسيط وruntime ذكي

أقسم Player ذهنيًا إلى جزأين:

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

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

إذا بسّطنا الدورة كثيرًا فستبدو هكذا:

Quest JSON + التقدم المحفوظ
          ↓
runtime يحدد الخطوة الحالية
          ↓
المشهد يعرض المقاطع والوسائط
          ↓
إجراء اللاعب يعود إلى runtime
          ↓
الخطوة التالية — وتتكرر الدورة حتى النهاية

في اللعبة المنشورة، يحمّل Player snapshot ثابتًا لـ runtime الخاص بالـ quest كاملة. وفي Builder ينشئ preview runtime متوافقًا من المسودة الحالية. لذلك فهما ليسا player متشابهين يبدآن مع الوقت في التصرف بصورة مختلفة، بل RuntimePlayer وSceneStage وviewport مشتركة داخل أغلفة مختلفة.

كيف نحافظ على الاستمرارية بين المشاهد

عند الانتقال، يعيد runtime حساب حالة المشهد الجديد. تُعرف الموسيقى المتكررة بواسطة asset ID وتستمر من دون بدء جديد أو fade-in آخر. ويحمّل المتصفح الصور ويخزنها في cache، لذلك لا يعني استخدام الخلفية نفسها تنزيل الملف من الشبكة مرة أخرى.

إذًا لا يوجد التحسين في شرط كبير واحد يقول «لا ترسل شيئًا»، بل في المستويات الصحيحة: يحافظ runtime على استمرارية التشغيل، ويعيد resolver موارد مستقرة، ولا تعيد cache المتصفح تنزيل الوسائط المعروفة.

شاشتان لمشهد واحد

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

للمشهد في Romergo اثنان من viewport الافتراضية الثابتة: أفقية بقياس 1280 × 720 وعمودية بقياس 390 × 693. يختار Player الوضع المناسب وفق الجهاز والاتجاه، ثم يغيّر حجم المشهد الجاهز بالكامل.

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

بالنسبة إلى الخلفية، تكون bgX وbgY والحجم مشتركة بين الوضعين، بينما تقص viewport العمودية الصورة نفسها بطريقتها. لذلك يجب فحص التأطير في التنسيقين قبل النشر واختيار نقطة تركيز مشتركة. إعدادات الخلفية المنفصلة للهاتف ليست جزءًا من عقد runtime.

يمكن إسناد تعديلات الشخصيات المتكررة إلى وكيل AI عبر MCP، ثم فحصها في preview الحقيقي لكلتا viewport. ولهذا أصف التكيف بأنه شبه تلقائي، لا تلقائي بالكامل.

كيف نتجنب عرض شاشة سوداء للاعب

لا يفيد المشهد البسيط إذا لم تصل الصورة المطلوبة عبر الشبكة بعد. لذلك أصبح التحميل أيضًا جزءًا من عقد runtime.

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

يرتبط التحميل المسبق برسم الانتقالات، ويمكن ضبط عمقه بشكل مستقل. ولا يُستخدم هنا عدد ثابت من المشاهد: فالتسلسل الخطي والتفرع يصنعان حملًا مختلفًا حتى لو كان أمامهما رسميًا العدد نفسه من الخطوات.

هناك وضع آخر للعب offline: يمكن للمستخدم تنزيل quest كاملة مسبقًا. عندها يبقى runtime snapshot والوسائط وغلاف PWA في cache المتصفح ويُفتح من دون شبكة. يضمن التحميل المسبق للمشهد التالي لعبًا سلسًا online، بينما يوفّر التنزيل الكامل لعبًا حقيقيًا offline.

Telegram كغلاف وDiscord كنظام منفصل

كان حبي للمتصفحات مفيدًا خصوصًا عند تضمين Player في Telegram وDiscord. ففي الحالتين يُفتح web runtime نفسه داخل المنصة، لذلك لم نضطر إلى إعادة كتابة المشهد وقواعد التقدم لمحرك لعبة جديد.

احتاج Telegram أساسًا إلى جلسة المنصة، وتشغيل quest، والتقدم المحلي، والتنقل بين Mini App وPlayer العادي. أما اللعبة نفسها فبقيت كما هي.

Discord أكثر تعقيدًا لأن عدة أشخاص يجب أن يروا النقطة نفسها في القصة. يعمل لكل مشارك runtime محلي خاص به بالفعل، لكن host هو مصدر الحقيقة. يرسل Player الخاص به الحالة عند التغييرات، وكذلك كل 750 مللي ثانية تقريبًا. تستقبل غرفة realtime الأمر عبر WebSocket، وتحفظ snapshot، وترسله إلى المشاركين. تطبق runtimes الخاصة بالمشاهدين الحالة البعيدة، وتستعيد المشهد، وتواصل حساب الوقت محليًا بين عمليات المزامنة.

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

أساس واحد لكل الأوضاع

تجاوز Player منذ زمن المجموعة الأساسية «الخلفية والشخصيات والحوار». وهو يتضمن الآن التفرعات والمتغيرات ونقاط الحفظ والإرجاع ومشاهد الواجهة التفاعلية والترجمات وoffline والتقدم المتزامن.

لكن القرار الأساسي تحمّل هذا النمو. لا يزال المشهد مسؤولًا عن الصورة والصوت. ولا يزال runtime مسؤولًا عن الحالة والانتقالات. تغطي Browser API عمليات rendering والوسائط وcache والتضمين، وتُضاف الطبقات المتخصصة فقط حيث تكون ضرورية فعلًا.

بفضل ذلك يمكن الحفاظ على Player نفسه في Builder preview والاختبارات واللعب العادي في المتصفح وTelegram وDiscord. بالنسبة إليّ، هذا مثال جيد على أن الحل الأبسط والخشن قليلًا قد يصبح الأكثر مرونة، إذا رُسمت حدود المسؤولية بصورة صحيحة.

العودة إلى مدونة التطوير