तांत्रिक कारणांमुळे खेळाडूंचा गेमचा प्रवास (run) मध्येच थांबणे त्यांना खूप नकोसे वाटते. प्लॅटफॉर्म स्पष्ट होता, टायमिंग योग्य होते, आणि मग खेळाडूने चूक केली नाही तरही गेमने त्यांना गेममधून बाहेर काढले, कारण ब्राउझर टॅबचे फोकस (focus) हरवले होते.

मी हे 'Solstice Leap' मध्ये प्रत्यक्ष अनुभवले, जे मी बनवलेले एक Three.js आर्केड गेम आहे. हे गेम एका साध्या पण समाधानकारक मेकॅनिकवर आधारित होते: उडी मारण्यासाठी (jump) बटण दाबून धरावे आणि नंतर ती उडी मारण्यासाठी बटण सोडावे. प्ले-टेस्टिंग दरम्यान, मला एक त्रासदायक पॅटर्न दिसून आला. जर कोणी मेसेजला उत्तर देण्यासाठी Alt-Tab केले किंवा चार्जिंग सुरू असताना दुसऱ्या टॅबवर क्लिक केले, तर विंडोवर पुन्हा क्लिक करताच किंवा कधीकधी फोकस हरवताच पात्र (character) थेट पोकळीत फेकला जायचा. ऑपरेटिंग सिस्टममधील एका सामान्य व्यत्ययाला (interruption) गेमने मुद्दाम बटण सोडल्याचे समजले होते. यामुळे खेळाडूंचा प्रवास अन्यायकारकपणे संपत असे आणि नियंत्रणावरील (controls) विश्वास कमी होत असे.

मूळ कारण: एकाच इव्हेंटचे दोन कामे

हा बग सूक्ष्म पण थेट होता. मूळ इनपुट लेयरमध्ये, कोडने जंप रिलीज लॉजिक थेट विंडोच्या blur इव्हेंटला जोडले होते:

window.addEventListener("blur", releaseCharge);

वरवर पाहता हे योग्य वाटू शकते. खेळाडूने एखादी की (key) किंवा पॉइंटर दाबून धरला होता आणि आता काहीतरी थांबले आहे. पण blur इव्हेंट हा इनपुट इव्हेंट नाही. तो विंडो मॅनेजमेंट सिग्नल आहे. जेव्हा ब्राउझर टॅबचे ऑपरेटिंग सिस्टममधील फोकस हरवते, तेव्हा तो फायर होतो. हे तेव्हा घडू शकते जेव्हा खेळाडू टॅब बदलतो, विंडो मिनिमाइज करतो, एक्सटर्नल मॉनिटरवर क्लिक करतो किंवा सिस्टिम नोटिफिकेशनमुळे फोकस बदलतो. यापैकी कोणत्याही कृतीचा अर्थ "मला माझे पात्र उडी मारण्यासाठी तयार करायचे आहे" असा होत नाही. त्याचा अर्थ असा असतो की "मी गेमच्या बाहेरच्या कशाशीतरी संवाद साधत आहे."

blur ला releaseCharge मध्ये समाविष्ट केल्यामुळे, गेमने दोन पूर्णपणे भिन्न संकल्पना एकत्र केल्या: एक हेतुपुरस्सर थांबणे (खेळाडूने बटण सोडणे) आणि एक बाह्य व्यत्यय (ब्राउझर आता ॲक्टिव्ह विंडो राहिलेली नाही). releaseCharge सध्याच्या चार्ज स्थितीवर आधारित जंप फोर्स मोजत असे आणि लगेच वेग (velocity) लागू करत असे, त्यामुळे चार्जिंग सुरू असताना फोकस हरवल्यास, जेवढी शक्ती साठली होती, त्यासह पात्र उडी मारत असे. खेळाडू जेव्हा परत यायचा, तेव्हा त्याला आपले पात्र मृत झालेले किंवा स्वतःच्या इच्छेविरुद्ध झालेल्या हालचालीमुळे प्रगती वाया गेल्याचे दिसायचे.

Three.js डेव्हलपर्ससाठी ब्राउझरची वास्तवता

Three.js तुम्हाला एक शक्तिशाली 3D कॅनव्हास देते, परंतु इनपुट अजूनही DOM द्वारेच प्रवाहित होते. ही विभागणी महत्त्वाची आहे. स्पेसबार दाबून धरल्याने जंप चार्ज होते, हे ब्राउझरला आपोआप माहित नसते. त्याला फक्त एवढेच माहित असते की एखादी की दाबली गेली आहे. जेव्हा फोकस डॉक्युमेंटमधून बाहेर जातो, तेव्हा ब्राउझर दाबून धरलेल्या प्रत्येक की साठी आपोआप keyup इव्हेंट तयार करत नाही. त्याऐवजी, तो फक्त एवढेच सांगतो की विंडो आता सक्रिय नाही. जर तुमच्या गेम लॉजिकने असे मानले की फोकस नसणे म्हणजे इनपुट नसणे, तर तुम्हाला 'फँटम ॲक्शन्स' (phantom actions) मिळतील.

ही फरक विशेषतः 'चार्ज-अप' मेकॅनिकसाठी महत्त्वाचा आहे, जे सर्वत्र पाहायला मिळते: धनुष्य ओढणे, वाहन वेगवान करणे, चार्ज स्पेल टाकणे किंवा स्टॅमिना वापरून धावणे. वेळेनुसार स्थिती (state) साठवणारी कोणतीही सततची कृती अशा चुकीच्या अर्थाला बळी पडू शकते. नेटिव्ह ॲप्लिकेशन्स सहसा फोकस हरवल्यावर संपूर्ण सिम्युलेशन थांबवतात. ब्राउझर गेम्स देखील असे करू शकतात, परंतु तुम्ही गेम चालू ठेवला तरीही, सिस्टिममधील व्यत्यय आणि खेळाडूचे कमांड्स वेगळे करणे आवश्यक आहे.

हेतू आणि व्यत्यय वेगळे करणे

यावर उपाय म्हणून चार्जिंग स्टेटमधून बाहेर पडण्याचे मार्ग दोन वेगळ्या मार्गांमध्ये विभागणे आवश्यक होते. एक मार्ग हेतुपुरस्सर दिलेल्या इनपुटसाठी आहे आणि दुसरा मार्ग बाह्य जगाचा व्यत्यय आल्यावर गेमची स्थिती सांभाळण्यासाठी आहे.

हेतुपुरस्सर रिलीजpointerup आणि keyup—अजूनही जंप कार्यान्वित करतात. हे खेळाडूचे प्रत्यक्ष सिग्नल आहेत.

फोकस लॉस इव्हेंट्सblur, pointercancel, आणि डॉक्युमेंट लपवल्यावर होणारा visibilitychange—आता cancelCharge नावाचे वेगळे फंक्शन ट्रिगर करतात.

cancelCharge हा केवळ बदललेला रिलीज नाही. तो एक 'हार्ड रिसेट' (hard reset) आहे. तो साठवलेली चार्ज फोर्स शून्य करतो, खेळाडूचा व्हिज्युअल स्केल त्याच्या मूळ 'आयडल' (idle) स्थितीत आणतो, स्क्रीनवरील चार्ज मीटर शून्य करतो आणि गेमला पुन्हा 'एमिंग मोड'मध्ये (aiming mode) आणतो. सर्वात महत्त्वाचे म्हणजे, तो लॉन्च ट्रॅजेक्टरी (launch trajectory) कोडला स्पर्श करत नाही. तिथे कोणताही वेग (velocity) कॅल्क्युलेशन, कोणताही फिजिक्स इम्पल्स (physics impulse) किंवा उडी नसते. चार्ज सुरक्षितपणे नाहीसा होतो.

अपडेटेड वायरिंग संकल्पनादृष्ट्या असे दिसते:

window.addEventListener("blur", cancelCharge);

परंतु खरा आर्किटेक्चरल बदल म्हणजे चार्जिंग आता दोन संभाव्य एक्झिट्स (exits) असलेली एक 'स्टेट' (state) आहे हे ओळखणे. योग्य रिलीजवर, स्टेट मशीन चार्ज टक्केवारी तपासते, जंप वेलोसिटी मोजते आणि लीप ॲनिमेशनमध्ये जाते. व्यत्यय आल्यास, स्टेट मशीन ते थांबवते आणि पुन्हा 'आयडल' स्थितीत येते. हे मार्ग वेगळे ठेवल्यामुळे अनपेक्षित परिणाम (side effects) टाळता येतात.

तुम्ही pointercancel साठी देखील लक्ष ठेवले पाहिजे. जेव्हा ब्राउझर पॉइंटिंग डिव्हाइसवर सिस्टम-स्तरीय व्यत्यय (interruption) ओळखतो—जसे की टचस्क्रीनवरील पाम रिजेक्शन जेस्चर (palm rejection gesture), सिस्टम मेनू इनव्होकेशन, किंवा असामान्य परिस्थितीत पेनचा संपर्क तुटणे—तेव्हा तो हे पाठवतो. blur ला pointercancel सोबत जोडल्यामुळे डेस्कटॉप मल्टिटास्किंग आणि मोबाईल व्यत्यय या दोन्ही गोष्टी कव्हर होतात. visibilitychange जोडल्यामुळे अशी परिस्थिती हाताळता येते जिथे वापरकर्ता विंडो ऑब्जेक्टवर blur फायर न करता टॅब बदलतो, जे काही ब्राउझर आणि OS च्या संयोजनांमध्ये घडू शकते.

सीमा परिस्थितींची चाचणी (Testing the Boundary Conditions)

इनपुट बग्स सुधारण्यासाठी 'हॅपी पाथ'च्या (happy path) बाहेर जाऊन चाचणी करणे आवश्यक आहे. केवळ एका टॅबमध्ये शांतपणे गेम खेळून कोणालाही या समस्या सापडणार नाहीत. नवीन वर्तन (behavior) तपासण्यासाठी, मी दोन विशिष्ट परिस्थिती (scenarios) राबवल्या.

प्रथम, मी जंप चार्ज करण्यास सुरुवात केली आणि नंतर कीबोर्ड वापरून ब्राउझर टॅब बदलून जबरदस्तीने blur इव्हेंट घडवून आणला. गेम त्वरित चार्जिंग मोडमधून बाहेर पडला आणि पुन्हा नेमिंग (aiming) मोडमध्ये आला. कोणताही जंप झाला नाही. कोणतीही वेलोसिटी (velocity) लागू झाली नाही. चार्ज मीटर स्वतःहून क्लिअर झाला. दुसरे म्हणजे, मी सामान्य चार्जिंग केले आणि जाणीवपूर्वक बटण सोडले. जंप पूर्वीप्रमाणेच, त्याच आर्क (arc) आणि फोर्स स्केलिंगसह (force scaling) कार्यान्वित झाला. गेमचा अनुभव (game feel) तसाच राहिला; फक्त 'एज केस' (edge case) दुरुस्त करण्यात आली.

दोन्ही मार्ग स्वतंत्र असणे आवश्यक होते. असा उपाय जो चुकून होणारे जंप्स रोखतो पण वैध जंप्सना मंदावतो, तो उपाय नसून एक वेगळा बग आहे. मूळ मेकॅनिकची अचूकता कायम ठेवत ब्राउझरमधील गोंधळापासून (chaos) त्याला सुरक्षित करणे हे उद्दिष्ट होते.

सततच्या इनपुटसाठी एक पॅटर्न

ही समस्या केवळ प्लॅटफॉर्मर्सपुरती मर्यादित नाही. सततच्या प्रेसवर अवलंबून असलेला कोणताही Three.js गेम याला असुरक्षित असतो. उदाहरणार्थ, एखादा फर्स्ट-पर्सन ग्रॅपलिंग हुक (grappling hook) जिथे माऊस दाबून धरल्याने ताण (tension) निर्माण होतो, किंवा एखादा रेसिंग गेम जिथे की (key) दाबून धरल्याने बूस्ट चार्ज होतो. जर तुमचे 'टीयरडाउन लॉजिक' (teardown logic) फक्त बटण रिलीज हँडलरमध्ये असेल आणि तुम्ही टॅब स्विचिंग, OS नोटिफिकेशन्स किंवा स्क्रीन लॉक यांचा विचार केला नाही, तर तुम्ही ऑपरेटिंग सिस्टमला तुमच्या वतीने गेम खेळू देत आहात.

व्यापक पॅटर्न असा आहे की तुमचे इनपुट लेयर तीन स्पष्ट स्थितींसह (states) तयार करा: active input, released input, आणि cancelled input. Active input चार्ज तयार करते किंवा कृती सुरू करते. Released input ती पूर्ण करते. Cancelled input ती स्वच्छपणे थांबवते. विंडो blur ला कधीही 'रिलीज' समजण्याची चूक करू नका. ब्राउझर हा होस्ट आहे, खेळाडू नाही.

मानवी वर्तनाचा विचार करा

लोक टॅब बदलतात. ते थेट संदेशांना (direct messages) उत्तर देतात. ते त्यांच्या दुसऱ्या मॉनिटरवर गाईड शोधतात. त्यांना कामाचे Slack पिंग्स येतात. या 'एज केसेस' नाहीत; ब्राउझरमधील हे सामान्य वर्तन आहे. सामान्य मानवी मल्टिटास्किंगला शिक्षा देणारा ब्राउझर गेम कमकुवत वाटतो. 'फोकस लॉस'ला कमांडऐवजी कॅन्सलेशन (cancellation) म्हणून मानल्यामुळे, Solstice Leap आता खेळाडूंना काळजीपूर्वक सेट केलेल्या जंपचा बळी न देता काही सेकंदांसाठी बाजूला जाण्याची परवानगी देते.

blur इव्हेंट म्हणजे 'रिलीज' इव्हेंट नाही. ब्राउझर फक्त असे सांगत आहे की तो खोलीबाहेर गेला आहे. त्यानुसार कोड लिहा, आणि तुमचे खेळाडू कंट्रोल्सवर इतका विश्वास ठेवतील की जेव्हा त्यांना खरोखर जंप करायची असेल, तेव्हा ते ते करू शकतील.