खिलाड़ी तकनीकी कारणों से अपना रन (run) हारना पसंद नहीं करते। प्लेटफॉर्म साफ था, टाइमिंग सही थी, और फिर गेम ने उन्हें इसलिए मार दिया क्योंकि उन्होंने कोई गलती नहीं की थी, बल्कि इसलिए क्योंकि ब्राउज़र टैब का फोकस (focus) हट गया था।

मैंने इसे Solstice Leap में खुद देखा, जो एक Three.js आर्केड गेम है जिसे मैंने एक ही संतोषजनक मैकेनिक (mechanic) के इर्द-गिर्द बनाया था: जंप को चार्ज करने के लिए एक बटन दबाकर रखें, फिर गैप्स (gaps) के पार जाने के लिए उसे छोड़ दें। प्लेटेस्ट (playtests) के दौरान, मैंने एक परेशान करने वाला पैटर्न देखा। यदि कोई चार्ज होने के दौरान मैसेज का जवाब देने के लिए Alt-Tab करता या किसी अन्य टैब पर क्लिक करता, तो जैसे ही विंडो वापस क्लिक होती—या कभी-कभी फोकस खोते ही—कैरेक्टर खुद को शून्य (void) में फेंक देता था। गेम ने ऑपरेटिंग सिस्टम के एक सामान्य व्यवधान (interruption) को बटन छोड़ने के इरादे के रूप में समझ लिया था। रन अनुचित रूप से समाप्त हो जाते थे। कंट्रोल्स पर से भरोसा उठने लगा था।

मूल कारण: एक ही इवेंट के दो काम

बग सूक्ष्म था लेकिन सीधा था। मूल इनपुट लेयर (input layer) में, कोड ने जंप रिलीज लॉजिक को सीधे विंडो के blur इवेंट से जोड़ दिया था:

window.addEventListener("blur", releaseCharge);

अगर आप ध्यान से देखें तो यह तर्कसंगत लगता है। खिलाड़ी ने एक की (key) या पॉइंटर दबाया हुआ था; अब कुछ रुक गया। लेकिन blur इवेंट कोई इनपुट इवेंट नहीं है। यह एक विंडो मैनेजमेंट सिग्नल है। यह तब फायर होता है जब ब्राउज़र टैब ऑपरेटिंग सिस्टम का फोकस खो देता है, जो तब हो सकता है जब खिलाड़ी टैब बदलता है, विंडो को मिनिमाइज करता है, किसी बाहरी मॉनिटर पर क्लिक करता है, या जब कोई सिस्टम नोटिफिकेशन फोकस छीन लेता है। इनमें से कोई भी क्रिया यह नहीं दर्शाती कि "मैं अपने कैरेक्टर को लॉन्च करना चाहता हूँ।" उनका अर्थ है "मैं गेम के बाहर किसी चीज़ के साथ इंटरैक्ट कर रहा हूँ।"

blur को releaseCharge में रूट करके, गेम ने दो पूरी तरह से अलग अवधारणाओं को मिला दिया: एक जानबूझकर किया गया ठहराव (खिलाड़ी बटन छोड़ देता है) और एक बाहरी व्यवधान (ब्राउज़र अब सक्रिय विंडो नहीं है)। क्योंकि releaseCharge वर्तमान चार्ज स्थिति के आधार पर जंप फोर्स की गणना करता था और तुरंत वेलोसिटी (velocity) लागू कर देता था, इसलिए चार्ज के बीच में फोकस खोने पर जमा हुई किसी भी शक्ति के साथ लॉन्च ट्रिगर हो जाता था। खिलाड़ी वापस आता तो पाता कि उसका कैरेक्टर मर चुका है या उसकी प्रगति उस चाल से बर्बाद हो गई है जिसे उसने कभी अनुमति नहीं दी थी।

Three.js डेवलपर्स के लिए ब्राउज़र की वास्तविकताएं

Three.js आपको एक शक्तिशाली 3D कैनवास देता है, लेकिन इनपुट अभी भी DOM के माध्यम से प्रवाहित होता है। यह विभाजन महत्वपूर्ण है। ब्राउज़र को स्वाभाविक रूप से यह नहीं पता होता कि स्पेसबार दबाए रखने से जंप चार्ज होती है। उसे केवल यह पता होता है कि एक की (key) दबाई गई है। जब फोकस डॉक्यूमेंट छोड़ देता है, तो ब्राउज़र हर दबी हुई की के लिए स्वचालित रूप से keyup सिंथेसाइज नहीं करता है। इसके बजाय, वह आपको बताता है कि विंडो चली गई है। यदि आपका गेम लॉजिक यह मान लेता है कि फोकस की अनुपस्थिति का मतलब इनपुट की अनुपस्थिति है, तो आपको 'फैंटम एक्शन' (phantom actions) मिलते हैं।

यह अंतर विशेष रूप से चार्ज-अप मैकेनिक के लिए महत्वपूर्ण है, जो हर जगह दिखाई देते हैं: धनुष खींचना, वाहन का रेव (rev) करना, चार्ज किया हुआ स्पेल (spell) कास्ट करना, या स्टैमिना विंड-अप के साथ स्प्रिंट करना। कोई भी निरंतर क्रिया जो समय के साथ स्टेट (state) जमा करती है, वह इसी तरह की गलत व्याख्या के प्रति संवेदनशील होती है। नेटिव एप्लिकेशन अक्सर फोकस खोने पर पूरी सिमुलेशन को रोक देते हैं। ब्राउज़र गेम भी ऐसा कर सकते हैं, लेकिन भले ही आप गेम को चलते रहने दें, आपको सिस्टम इंटरप्ट्स (interrupts) को प्लेयर कमांड से अलग करना चाहिए।

इरादे को व्यवधान से अलग करना

समाधान के लिए चार्जिंग स्टेट से एग्जिट पाथ (exit path) को दो अलग-अलग रास्तों में विभाजित करने की आवश्यकता थी। एक रास्ता जानबूझकर किए गए इनपुट को संभालता है। दूसरा रास्ता तब 'लाइफ सपोर्ट' का काम करता है जब वास्तविक दुनिया हस्तक्षेप करती है।

जानबूझकर किए गए रिलीजpointerup और keyup—अभी भी जंप को निष्पादित (execute) करते हैं। ये जाने के लिए खिलाड़ी के सीधे संकेत हैं।

फोकस लॉस इवेंट्सblur, pointercancel, और visibilitychange जब डॉक्यूमेंट छिप जाता है—अब cancelCharge नामक एक अलग फंक्शन को ट्रिगर करते हैं।

cancelCharge कोई संशोधित रिलीज नहीं है। यह एक हार्ड रीसेट (hard reset) है। यह संचित चार्ज फोर्स को वापस शून्य कर देता है, खिलाड़ी के विजुअल स्केल को उसकी डिफ़ॉल्ट आइडल स्टेट (idle state) पर वापस ले आता है, ऑन-स्क्रीन चार्ज मीटर को शून्य कर देता है, और गेम को उसके एमिंग मोड (aiming mode) में वापस ले आता है। सबसे महत्वपूर्ण बात यह है कि यह लॉन्च ट्रेजेक्टरी (trajectory) कोड को नहीं छूता है। इसमें कोई वेलोसिटी कैलकुलेशन नहीं, कोई फिजिक्स इम्पल्स (impulse) नहीं, और कोई लीप (leap) नहीं होता है। चार्ज सुरक्षित रूप से समाप्त हो जाता है।

अपडेटेड वायरिंग वैचारिक रूप से ऐसी दिखती है:

window.addEventListener("blur", cancelCharge);

लेकिन वास्तविक आर्किटेक्चरल बदलाव यह पहचानना है कि चार्जिंग अब दो संभावित निकास (exits) वाली एक स्टेट है। एक उचित रिलीज पर, स्टेट मशीन चार्ज प्रतिशत का मूल्यांकन करती है, जंप वेलोसिटी की गणना करती है, और लीप एनिमेशन में ट्रांज़िशन करती है। इंटरप्ट होने पर, स्टेट मशीन को रद्द कर दिया जाता है और वह आइडल (idle) पर वापस आ जाती है। इन रास्तों को अलग रखने से साइड इफेक्ट्स (side effects) से बचाव होता है।

आपको pointercancel को भी सुनना चाहिए। ब्राउज़र इसे तब डिस्पैच करता है जब वह पॉइंटिंग डिवाइस पर सिस्टम-स्तर के व्यवधान (interruption) का पता लगाता है—जैसे टचस्क्रीन पर पाम रिजेक्शन जेस्चर, सिस्टम मेनू का आह्वान, या असामान्य परिस्थितियों में पेन का संपर्क टूटना। blur को pointercancel के साथ जोड़ने से डेस्कटॉप मल्टीटास्किंग और मोबाइल व्यवधान दोनों कवर हो जाते हैं। visibilitychange जोड़ने से उस स्थिति को पकड़ा जा सकता है जहाँ उपयोगकर्ता बिना window ऑब्जेक्ट पर blur फायर किए टैब बदल देता है, जो कुछ ब्राउज़र और OS संयोजनों में हो सकता है।

बाउंड्री कंडीशंस (Boundary Conditions) का परीक्षण करना

इनपुट बग्स को ठीक करने के लिए 'हैप्पी पाथ' (happy path) से बाहर परीक्षण करने की आवश्यकता होती है। कोई भी एक सिंगल टैब में शांति से गेम खेलकर इन समस्याओं को नहीं ढूंढ पाता। नए व्यवहार को सत्यापित करने के लिए, मैंने दो विशिष्ट परिदृश्य (scenarios) चलाए।

सबसे पहले, मैंने जंप को चार्ज करना शुरू किया और फिर कीबोर्ड का उपयोग करके ब्राउज़र टैब बदलकर एक blur इवेंट को फोर्स किया। गेम तुरंत चार्जिंग मोड से बाहर आ गया और ऐमिंग (aiming) मोड में वापस आ गया। कोई जंप नहीं हुआ। कोई वेलोसिटी (velocity) लागू नहीं हुई। चार्ज मीटर अपने आप क्लियर हो गया। दूसरा, मैंने एक सामान्य चार्ज किया और जानबूझकर बटन छोड़ दिया। जंप बिल्कुल वैसे ही निष्पादित हुआ जैसे पहले होता था, उसी आर्क (arc) और फोर्स स्केलिंग के साथ। गेम का अहसास (game feel) बरकरार रहा; केवल एज केस (edge case) को पैच किया गया था।

दोनों रास्ते स्वतंत्र रहने चाहिए थे। एक ऐसा फिक्स जो आकस्मिक जंप को तो रोकता है लेकिन वैध जंप को धीमा कर देता है, वह फिक्स नहीं है—वह एक अलग बग है। ब्राउज़र की अव्यवस्था (chaos) के खिलाफ इसे मजबूत करते हुए मूल मैकेनिक की स्पष्टता (crispness) को बनाए रखना ही लक्ष्य था।

निरंतर इनपुट के लिए एक पैटर्न

यह समस्या प्लेटफॉर्मर्स (platformers) से कहीं आगे तक फैली हुई है। कोई भी Three.js गेम जो निरंतर प्रेस (continuous press) पर निर्भर करता है, वह इसके प्रति संवेदनशील है। एक फर्स्ट-पर्सन ग्रैपलिंग हुक (grappling hook) के बारे में सोचें जहाँ माउस को दबाए रखने से तनाव (tension) बनता है, या एक रेसिंग गेम जहाँ एक की (key) को दबाए रखने से बूस्ट चार्ज होता है। यदि आपका teardown logic केवल एक बटन रिलीज़ हैंडलर में रहता है, और आप टैब स्विचिंग, OS नोटिफिकेशन, या स्क्रीन लॉक का ध्यान नहीं रखते हैं, तो आप ऑपरेटिंग सिस्टम को अपने लिए गेम खेलने की अनुमति दे रहे हैं।

व्यापक पैटर्न यह है कि अपने इनपुट लेयर को तीन स्पष्ट अवस्थाओं (states) के साथ बनाया जाए: active input, released input, और cancelled inputactive input चार्ज बनाता है या एक्शन शुरू करता है। released input उसे कमिट (commit) करता है। cancelled input उसे सफाई से खत्म कर देता है। window blur को कभी भी रिलीज़ के रूप में छिपने न दें। ब्राउज़र एक होस्ट है, खिलाड़ी नहीं।

मानवीय व्यवहार को ध्यान में रखें

लोग टैब बदलते हैं। वे डायरेक्ट मैसेज का जवाब देते हैं। वे अपने दूसरे मॉनिटर पर गाइड देखते हैं। उन्हें काम के Slack पिंग मिलते हैं। ये एज केस नहीं हैं; ये ब्राउज़र के भीतर मानक व्यवहार हैं। एक ब्राउज़र गेम जो सामान्य मानवीय मल्टीटास्किंग को दंडित करता है, वह कमजोर महसूस होता है। फोकस लॉस को कमांड के बजाय कैंसलेशन (cancellation) के रूप में मानकर, Solstice Leap अब खिलाड़ियों को एक सावधानीपूर्वक सेट किए गए जंप को खराब किए बिना एक सेकंड के लिए दूर जाने की अनुमति देता है।

एक blur इवेंट रिलीज़ इवेंट नहीं है। यह केवल ब्राउज़र का यह कहना है कि वह कमरे से बाहर चला गया है। उसी के अनुसार कोड लिखें, और आपके खिलाड़ी कंट्रोल्स पर इतना भरोसा करेंगे कि जब वे वास्तव में जंप करना चाहें, तो वे ऐसा कर सकें।