שחקנים שונאים להפסיד ריצה בגלל פרט טכני. הפלטפורמה הייתה ברורה, התזמון היה נכון, ואז המשחק הרג אותם לא בגלל טעות שהם עשו, אלא בגלל שללשונית הדפדפן אבד הפוקוס.
ראיתי את זה ממקור ראשון ב-Solstice Leap, משחק ארקייד ב-Three.js שבניתי סביב מכניקה אחת מספקת: לחיצה ממושכת על כפתור כדי לצבור כוח לקפיצה, ואז שחרור שלו כדי להמריא מעל פערים. במהלך בדיקות המשחק, שמתי לב לדפוס מעצבן. אם מישהו עשה Alt-Tab כדי לענות להודעה או לחץ על לשונית אחרת בזמן שהטעינה הייתה בעיצומה, הדמות הייתה משליכה את עצמה אל התהום ברגע שהחלון חזר למקדמת (focus) — או לפעמים מיד עם אובדן הפוקוס. המשחק פירש הפרעה שגרתית של מערכת ההפעלה ככוונה לשחרר את הכפתור. הריצות הסתיימו בצורה לא הוגנת. האמון בשליטה נשחק.
שורש הבעיה: אירוע אחד שמבצע שתי משימות
הבאג היה עדין אך ישיר. בשכבת הקלט המקורית, הקוד חיבר את הלוגיקה של שחרור הקפיצה ישירות לאירוע ה-blur של החלון:
window.addEventListener("blur", releaseCharge);
זה נראה הגיוני במבט ראשון. השחקן החזיק מקש או סמן; עכשיו משהו נעצר. אבל אירוע blur אינו אירוע קלט. הוא אות לניהול חלונות. הוא מופעל כאשר ללשונית הדפדפן אבד הפוקוס של מערכת ההפעלה, מה שיכול לקרות כשהשחקן מחליף לשוניות, ממזער את החלון, לוחץ על מסך חיצוני, או אפילו כשהתראה של מערכת ההפעלה גונבת את הפוקוס. אף אחת מהפעולות הללו לא אומרת "אני רוצה להמריא עם הדמות שלי". הן אומרות "אני מתקשר עם משהו מחוץ למשחק".
על ידי ניתוב ה-blur לתוך releaseCharge, המשחק ערבב בין שני מושגים שונים לחלוטין: עצירה מכוונת (השחקן משחרר את הכפתור) והפרעה חיצונית (הדפדפן אינו lagi החלון הפעיל). מכיוון ש-releaseCharge חישב את כוח הקפיצה על סמך מצב הטעינה הנוכחי ויישם מיד מהירות, כל אובדן פוקוס באמצע הטעינה גרם להמראה עם כל הכוח שנצבר עד לאותו רגע. השחקן חזר כדי למצוא את הדמות שלו מתה או את ההתקדמות שלו הרוסה בגלל תנועה שמעולם לא אישר.
מציאות הדפדפן עבור מפתחי Three.js
Three.js מעניקה לכם קנבס תלת-ממדי עוצמתי, אך הקלט עדיין עובר דרך ה-DOM. הפיצול הזה חשוב. הדפדפן לא יודע באופן מובנה שלחיצה ממושכת על מקש הרווח מצביעה על צבירת כוח לקפיצה. הוא יודע רק שמקש נלחץ. כאשר הפוקוס עוזב את המסמך, הדפדפן לא יוצר באופן אוטומטי אירוע keyup עבור כל מקש שנמצא לחוץ. במקום זאת, הוא אומר לכם שהחלון אינו פעיל. אם לוגיקת המשחק שלכם מניחה שהיעדר פוקוס שווה להיעדר קלט, תקבלו פעולות רפאים.
ההבחנה הזו חשובה במיוחד עבור מכניקות של צבירת כוח, המופיעות בכל מקום: טעינת קשת, האצת רכב, הטלת לחש טעון, או ריצה עם צבירת סיבולת. כל פעולה מתמשכת שמצטברת לאורך זמן פגיעה לאותה פרשנות שגויה. אפליקציות native נוטות לעצור את הסימולציה כולה בעת אובדן פוקוס. משחקי דפדפן יכולים לעשות את אותו הדבר, אך גם אם אתם ממשיכים להריץ את המשחק, עליכם להפריד בין הפרעות מערכת לבין פקודות שחקן.
הפרדת הכוונה מהפרעה
התיקון דרש פיצול של נתיב היציאה ממצב הצבירה לשני נתיבים נפרדים. נתיב אחד מטפל בקלט מכוון. השני מטפל ב"תמיכה בחיים" למקרים שבהם העולם האמיתי חודר פנימה.
שחרורים מכוונים — pointerup ו-keyup — עדיין מבצעים את הקפיצה. אלו הם האותות הישירים של השחקן לפעול.
אירועי אובדן פוקוס — blur, pointercancel, ו-visibilitychange כאשר המסמך הופך למוסתר — מפעילים כעת פונקציה נפרדת בשם cancelCharge.
cancelCharge אינו שחרור משופר. זהו איפוס מוחלט (hard reset). הוא מרוקן את כוח הצבירה שנצבר חזרה לאפס, מחזיר את קנה המידה הוויזואלי של השחקן למצב ה-idle ברירת המחדל שלו, מאפס את מד הצבירה שעל המסך, ומחזיר את המשחק למצב כיוון (aiming mode). והכי חשוב, הוא לא נוגע בקוד של מסלול ההמראה. אין חישוב מהירות, אין דחף פיזיקלי, ואין קפיצה. המטען פשוט מתאדה בבטחה.
החיווט המעודכן נראה מבחינה קונספטואלית כך:
window.addEventListener("blur", cancelCharge);
אך השינוי הארכיטקטוני האמיתי הוא ההכרה בכך שצבירת כוח היא כעת מצב (state) עם שתי אפשרויות יציאה. בשחרור תקין, מכונת המצבים (state machine) מעריכה את אחוז הצבירה, מחשבת את מהירות הקפיצה ועוברת לאנימציית הקפיצה. בהפרעה, מכונת המצבים מבטלת את הפעולה וחוזרת למצב idle. שמירה על נתיבים נפרדים מונעת השפעות לוואי (side effects).
כדאי גם להאזין ל-pointercancel. הדפדפן מפעיל אירוע זה כאשר הוא מזהה הפרעה ברמת המערכת במכשיר ההצבעה — כמו תנועת דחיית כף יד (palm rejection) במסכי מגע, קריאה לתפריט המערכת, או עט שמאבד מגע בתנאים חריגים. שילוב של blur עם pointercancel מכסה הן ריבוי משימות בשולחן עבודה והן הפרעות בנייד. הוספת visibilitychange תופסת את התרחיש שבו משתמש מחליף טאבים מבלי להפעיל בהכרח את blur על אובייקט ה-window עצמו, מה שיכול לקרות בשילובים מסוימים של דפדפן ומערכת הפעלה.
בדיקת תנאי הקצה
תיקון באגים בקלט דורש בדיקה מחוץ ל-"happy path" (הנתיב התקין). אף אחד לא מוצא את הבעיות הללו על ידי משחק רגוע במשחק בטאב בודד. כדי לוודא את ההתנהגות החדשה, הרצתי שני תרחישים ספציפיים.
ראשית, התחלתי לצבור אנרגיה לקפיצה ואז אילצתי אירוע blur על ידי החלפת טאבים בדפדפן באמצעות המקלדת. המשחק מיד יצא ממצב צבירת אנרגיה וחזר למצב כיוון. לא בוצעה קפיצה. לא הוחל מהירות. מד הצבירה התאפס. שנית, ביצעתי צבירה רגילה ושחררתי את הכפתור בכוונה. הקפיצה בוצעה בדיוק כפי שבוצעה קודם לכן, עם אותה קשת ואותו קנה מידה של כוח. תחושת המשחק נותרה ללא שינוי; רק מקרה הקצה תוקן.
שני המסלולים היו חייבים להישאר עצמאיים. תיקון שמונע קפיצות מקריות אך מחליש קפיצות לגיטימיות אינו תיקון — הוא באג אחר. המטרה הייתה לשמר את החדות של המכניקה המקורית תוך חיזוקה מפני הכאוס של הדפדפן.
תבנית לקלט מתמשך
הבעיה הזו חורגת הרבה מעבר למשחקי פלטפורמה. כל משחק Three.js שמסתמך על לחיצה רציפה חשוף לכך. שקלו וו חוט (grappling hook) בגוף ראשון שבו לחיצה על העכבר בונה מתח, או משחק מרוצים שבו לחיצה על מקש טוענת בוסט. אם לוגיקת ה-teardown שלך קיימת רק במטפל (handler) של שחרור כפתור, ואינך לוקח בחשבון החלפת טאבים, התראות מערכת הפעלה או נעילת מסך, אתה מאפשר למערכת ההפעלה לשחק את המשחק שלך במקומך.
התבנית הרחבה יותר היא לבנות את שכבת הקלט שלך עם שלושה מצבים מפורשים: קלט פעיל (active input), קלט משוחרר (released input), וקלט מבוטל (cancelled input). קלט פעיל בונה את הצבירה או יוזם את הפעולה. קלט משוחרר מאשר אותה. קלט מבוטל מפסיק אותה בצורה נקייה. לעולם אל תתנו ל-blur של חלון להתחזות לשחרור. הדפדפן הוא מארח, לא שחקן.
קחו בחשבון התנהגות אנושית
אנשים מחליפים טאבים. הם עונים להודעות ישירות. הם מחפשים מדריך במסך השני שלהם. הם מקבלים התראות Slack מהעבודה. אלו אינם מקרי קצה; אלו התנהגויות סטנדרטיות בתוך דפדפן. משחק דפדפן שמעניש ריבוי משימות אנושי רגיל מרגיש שברירי. על ידי התייחסות לאובדן פוקוס כאל ביטול ולא כאל פקודה, Solstice Leap מאפשרת כעת לשחקנים להתרחק לשנייה מבלי להקריב קפיצה שהוגדרה בקפידה.
אירוע blur אינו אירוע שחרור. זה פשוט הדפדפן שאומר שהוא יצא מהחדר. כתבו קוד בהתאם, ושחקניכם יסמכו על הבקרים מספיק כדי לבצע את הקפיצה כשהם באמת מתכוונים לכך.
