يكره اللاعبون خسارة محاولاتهم بسبب تفاصيل تقنية. كانت المنصة واضحة، والتوقيت مثاليًا، ثم قتلتهم اللعبة ليس لأنهم ارتكبوا خطأً، بل لأن علامة تبويب المتصفح فقدت التركيز (focus).
رأيت هذا بنفسي في Solstice Leap، وهي لعبة أركيد مبنية باستخدام Three.js قمت ببنائها حول ميكانيكية واحدة مرضية: الضغط المستمر على زر لشحن القفزة، ثم إفلاته للانطلاق عبر الفجوات. خلال اختبارات اللعب، لاحظت نمطًا مثيرًا للإحباط. إذا قام شخص ما باستخدام Alt-Tab للرد على رسالة أو نقر على علامة تبويب أخرى أثناء شحن القفزة، فإن الشخصية كانت تقذف نفسها في الفراغ بمجرد العودة إلى النافذة — أو أحيانًا فور فقدان التركيز. كانت اللعبة قد فسرت انقطاعًا روتينيًا في نظام التشغيل على أنه إفلات متعمد للزر. انتهت المحاولات بشكل غير عادل، وتآكلت الثقة في أدوات التحكم.
السبب الجذري: حدث واحد يقوم بوظيفتين
كان الخطأ البرمجي خفيًا ولكنه مباشر. في طبقة الإدخال الأصلية، قام الكود بربط منطق إفلات القفزة مباشرة بحدث الـ blur الخاص بالنافذة:
window.addEventListener("blur", releaseCharge);
يبدو هذا منطقيًا للوهلة الأولى. كان اللاعب يضغط على مفتاح أو مؤشر؛ والآن توقف شيء ما. لكن حدث الـ blur ليس حدث إدخال (input event)، بل هو إشارة لإدارة النوافذ. يتم إطلاقه عندما تفقد علامة تبويب المتصفح التركيز من نظام التشغيل، وهو ما يمكن أن يحدث عندما ينتقل اللاعب بين علامات التبويب، أو يصغر حجم النافذة، أو ينقر على شاشة خارجية، أو حتى عندما يسرق إشعار نظام التركيز. لا تعني أي من هذه الإجراءات "أريد إطلاق شخصيتي"، بل تعني "أنا أتفاعل مع شيء خارج اللعبة".
من خلال توجيه حدث الـ blur إلى releaseCharge ، خلطت اللعبة بين مفهومين مختلفين تمامًا: التوقف المتعمد (إفلات اللاعب للزر) والانقطاع الخارجي (لم يعد المتصفح هو النافذة النشطة). ولأن releaseCharge كانت تحسب قوة القفزة بناءً على حالة الشحن الحالية وتطبق السرعة فورًا، فإن أي فقدان للتركيز أثناء الشحن كان يؤدي إلى إطلاق الشخصية بأي قوة تراكمت. يعود اللاعب ليجد شخصيته قد ماتت أو تقدمه قد دُمّر بحركة لم يأذن بها أبدًا.
واقع المتصفحات لمطوري Three.js
تمنحك Three.js مساحة رسم (canvas) ثلاثية الأبعاد قوية، ولكن الإدخال لا يزال يتدفق عبر الـ DOM. هذا الانفصال مهم. المتصفح لا يعرف بطبيعته أن الضغط المستمر على مفتاح المسافة (spacebar) يشحن القفزة؛ هو يعرف فقط أن مفتاحًا قد ضُغط. عندما يخرج التركيز من المستند، لا يقوم المتصفح تلقائيًا بإنشاء حدث keyup لكل مفتاح مضغوط. بدلاً من ذلك، يخبرك بأن النافذة قد اختفت. إذا افترض منطق اللعبة الخاص بك أن غياب التركيز يعني غياب الإدخال، فستحصل على أفعال وهمية (phantom actions).
هذا التمييز مهم بشكل خاص لميكانيكيات الشحن (charge-up mechanics) التي تظهر في كل مكان: سحب القوس، أو تسريع مركبة، أو إلقاء تعويذة مشحونة، أو الركض مع استهلاك الطاقة. أي إجراء مستمر يراكم حالة معينة بمرور الوقت يكون عرضة لنفس سوء التفسير. غالبًا ما تقوم التطبيقات الأصلية (Native applications) بإيقاف المحاكاة بأكملها عند فقدان التركيز. يمكن لألعاب المتصفح فعل الشيء نفسه، ولكن حتى لو استمرت اللعبة في العمل، يجب عليك فصل انقطاعات النظام عن أوامر اللاعب.
فصل النية عن الانقطاع
تطلب الإصلاح تقسيم مسار الخروج من حالة الشحن إلى مسارين متميزين. مسار يتعامل مع الإدخال المتعمد، والآخر يتعامل مع "دعم الحياة" عندما يتدخل العالم الحقيقي.
الإفلاتات المتعمدة—pointerup و keyup—لا تزال تنفذ القفزة. هذه هي إشارات اللاعب المباشرة للانطلاق.
أحداث فقدان التركيز—blur و pointercancel و visibilitychange عندما يصبح المستند مخفيًا—تقوم الآن بتشغيل دالة منفصلة تسمى cancelCharge.
دالة cancelCharge ليست مجرد إفلات معدل، بل هي إعادة ضبط كاملة (hard reset). فهي تفرغ قوة الشحن المتراكمة لتعود إلى الصفر، وتستعيد المقياس البصري للاعب إلى حالة الخمول الافتراضية، وتصفر عداد الشحن على الشاشة، وتعيد اللعبة إلى وضع التصويب. والأهم من ذلك، أنها لا تلمس كود مسار الإطلاق؛ فلا توجد حسابات للسرعة، ولا نبضات فيزيائية، ولا قفزة. تتبخر الشحنة بأمان.
يبدو الربط المحدث من الناحية المفاهيمية كالتالي:
window.addEventListener("blur", cancelCharge);
لكن التغيير المعماري الحقيقي هو الإدراك بأن الشحن أصبح الآن حالة لها مخرجان محتملان. عند الإفلات الصحيح، تقوم آلة الحالة (state machine) بتقييم نسبة الشحن، وحساب سرعة القفزة، والانتقال إلى حركة القفز. أما عند حدوث انقطاع، فتقوم آلة الحالة بالإلغاء والعودة إلى حالة الخمول. إن الحفاظ على هذه المسارات منفصلة يمنع حدوث آثار جانبية.
يجب عليك أيضًا الاستماع إلى pointercancel. يرسل المتصفح هذا الحدث عندما يكتشف مقاطعة على مستوى النظام في جهاز التأشير — مثل إيماءة رفض راحة اليد على شاشات اللمس، أو استدعاء قائمة النظام، أو فقدان القلم للاتصال في ظروف غير معتادة. إن الجمع بين blur و pointercancel يغطي كلاً من تعدد المهام على أجهزة سطح المكتب والمقاطعات على الأجهزة المحمولة. وإضافة visibilitychange تضمن معالجة السيناريو الذي يقوم فيه المستخدم بتبديل علامات التبويب دون إطلاق حدث blur بالضرورة على كائن النافذة (window object) نفسه، وهو ما قد يحدث في بعض تركيبات المتصفحات وأنظمة التشغيل.
اختبار الحالات الحدية
يتطلب إصلاح أخطاء الإدخال الاختبار خارج المسار المثالي (happy path). لا أحد يكتشف هذه المشكلات من خلال لعب اللعبة بهدوء في علامة تبويب واحدة. وللتحقق من السلوك الجديد، قمت بتشغيل سيناريوهين محددين.
أولاً، بدأت في شحن القفزة ثم أجبرت حدث blur عن طريق تبديل علامات تبويب المتصفح باستخدام لوحة المفاتيح. خرجت اللعبة فوراً من وضع الشحن وعادت إلى وضع التصويب. لم تنطلق القفزة. لم يتم تطبيق أي سرعة. ومسح مقياس الشحن نفسه. ثانياً، قمت بعملية شحن عادية وأطلقت الزر عن قصد. نُفذت القفزة تماماً كما كانت من قبل، بنفس القوس وتدرج القوة. ظل شعور اللعبة كما هو؛ تم إصلاح الحالة الحدية فقط.
كان يجب أن يظل كلا المسارين مستقلين. الإصلاح الذي يمنع القفزات العرضية ولكنه يضعف القفزات المشروعة ليس إصلاحاً، بل هو خطأ برمجي آخر. كان الهدف هو الحفاظ على دقة الميكانيكية الأصلية مع تحصينها ضد فوضى المتصفح.
نمط للإدخال المستمر
تمتد هذه المشكلة إلى ما هو أبعد بكثير من ألعاب المنصات (platformers). أي لعبة Three.js تعتمد على الضغط المستمر تكون معرضة لهذه المشكلة. فكر في خطاف تسلق بمنظور الشخص الأول حيث يؤدي الضغط المستمر على الماوس إلى بناء التوتر، أو لعبة سباق حيث يؤدي الضغط المستمر على مفتاح إلى شحن التعزيز (boost). إذا كان منطق إلغاء الشحن (teardown logic) الخاص بك يعيش فقط في معالج إطلاق الزر (button release handler)، ولم تأخذ في الاعتبار تبديل علامات التبويب، أو إشعارات نظام التشغيل، أو قفل الشاشة، فأنت تسمح لنظام التشغيل بلعب لعبتك بدلاً منك.
النمط الأوسع هو بناء طبقة الإدخال الخاصة بك بثلاث حالات صريحة: الإدخال النشط (active input)، والإدخال المُطلق (released input)، والإدخال الملغى (cancelled input). الإدخال النشط يبني الشحن أو يبدأ الإجراء. الإدخال المُطلق يؤكده. والإدخال الملغى ينهيه بشكل نظيف. لا تسمح أبداً لحدث blur للنافذة بالتنكر في شكل إطلاق زر. المتصفح هو مضيف، وليس لاعباً.
ضع السلوك البشري في الاعتبار
الناس يبدلون علامات التبويب. يردون على الرسائل المباشرة. يبحثون عن دليل على شاشتهم الثانية. يتلقون تنبيهات Slack للعمل. هذه ليست حالات حدية؛ بل هي سلوك قياسي داخل المتصفح. لعبة المتصفح التي تعاقب المستخدم على تعدد المهام البشري الطبيعي تبدو هشة. من خلال التعامل مع فقدان التركيز (focus loss) كإلغاء بدلاً من أمر، تتيح Solstice Leap الآن للاعبين الابتعاد لثانية دون التضحية بقفزة تم إعدادها بعناية.
حدث blur ليس حدث إطلاق زر. إنه ببساطة المتصفح يقول إنه خرج من الغرفة. برمج بناءً على ذلك، وسيثق لاعبوك في أدوات التحكم بما يكفي للقيام بالقفزة عندما يقصدون ذلك حقاً.
