ผู้เล่นเกลียดการต้องมาเสียสถิติหรือแพ้เกมเพราะเรื่องทางเทคนิคเล็กๆ น้อยๆ แพลตฟอร์มก็ชัดเจน จังหวะก็ใช่ แต่แล้วเกมก็ทำให้พวกเขาตาย ไม่ใช่เพราะพวกเขาทำพลาด แต่เป็นเพราะแท็บเบราว์เซอร์เสียโฟกัสไป
ผมเห็นเรื่องนี้กับตาตัวเองใน Solstice Leap ซึ่งเป็นเกมอาร์เคดที่สร้างด้วย Three.js โดยมีกลไกหลักที่น่าพึงพอใจเพียงอย่างเดียวคือ: กดปุ่มค้างไว้เพื่อชาร์จการกระโดด จากนั้นจึงปล่อยเพื่อพุ่งข้ามช่องว่าง ระหว่างการทดสอบเล่น (playtests) ผมสังเกตเห็นรูปแบบที่น่าหงุดหงิดอย่างหนึ่ง หากใครสักคนกด Alt-Tab เพื่อตอบข้อความหรือคลิกแท็บอื่นในขณะที่กำลังชาร์จ ตัวละครจะพุ่งเข้าหาความว่างเปล่าทันทีที่คลิกกลับมาที่หน้าต่าง—หรือบางครั้งก็พุ่งทันทีที่เสียโฟกัส เกมตีความการขัดจังหวะตามปกติของระบบปฏิบัติการว่าเป็นการปล่อยปุ่มโดยตั้งใจ ทำให้การเล่นต้องจบลงอย่างไม่ยุติธรรม และความเชื่อมั่นในการควบคุมก็ลดน้อยลง
สาเหตุที่แท้จริง: เมื่อเหตุการณ์เดียวทำหน้าที่สองอย่าง
บั๊กนี้ดูเหมือนจะเล็กน้อยแต่ส่งผลโดยตรง ในเลเยอร์อินพุต (input layer) เดิม โค้ดได้ผูกตรรกะการปล่อยการกระโดดเข้ากับเหตุการณ์ blur ของหน้าต่างโดยตรง:
window.addEventListener("blur", releaseCharge);
มันดูสมเหตุสมผลถ้ามองผ่านๆ ผู้เล่นกำลังกดปุ่มหรือตัวชี้เมาส์ค้างไว้ แล้วจู่ๆ บางอย่างก็หยุดลง แต่ blur event ไม่ใช่อินพุต event มันคือสัญญาณการจัดการหน้าต่าง (window management signal) มันจะทำงานเมื่อแท็บเบราว์เซอร์เสียโฟกัสจากระบบปฏิบัติการ ซึ่งเกิดขึ้นได้เมื่อผู้เล่นสลับแท็บ, ย่อหน้าต่าง, คลิกหน้าจอมอนิเตอร์อื่น หรือแม้แต่เมื่อมีการแจ้งเตือนจากระบบเด้งขึ้นมาขัดจังหวะ การกระทำเหล่านั้นไม่ได้หมายความว่า "ฉันต้องการให้ตัวละครพุ่งออกไป" แต่มันหมายความว่า "ฉันกำลังโต้ตอบกับบางอย่างนอกเกม"
การส่ง blur เข้าไปใน releaseCharge ทำให้เกมเอาสองแนวคิดที่แตกต่างกันโดยสิ้นเชิงมาปนกัน นั่นคือ การหยุดโดยตั้งใจ (ผู้เล่นปล่อยปุ่ม) และการขัดจังหวะจากภายนอก (เบราว์เซอร์ไม่ได้เป็นหน้าต่างที่ใช้งานอยู่) เนื่องจาก releaseCharge คำนวณแรงกระโดดจากสถานะการชาร์จปัจจุบันและใช้ความเร็ว (velocity) ทันที การเสียโฟกัสระหว่างการชาร์จจึงไปกระตุ้นการพุ่งตัวด้วยพลังทั้งหมดที่สะสมไว้ ผู้เล่นกลับมาพบว่าตัวละครตายหรือความคืบหน้าพังพินาศด้วยการเคลื่อนที่ที่พวกเขาไม่ได้สั่งการ
ความเป็นจริงของเบราว์เซอร์สำหรับนักพัฒนา Three.js
Three.js มอบ 3D canvas ที่ทรงพลังให้คุณ แต่การรับอินพุตยังคงไหลผ่าน DOM ความแตกต่างนี้สำคัญมาก เบราว์เซอร์ไม่ได้รู้โดยธรรมชาติว่าการกด spacebar ค้างไว้คือการชาร์จการกระโดด มันรู้แค่ว่ามีการกดปุ่ม เมื่อโฟกัสหลุดออกจากเอกสาร เบราว์เซอร์จะไม่สร้างเหตุการณ์ keyup ให้โดยอัตโนมัติสำหรับทุกปุ่มที่กดค้างไว้ แต่มันจะบอกคุณว่าหน้าต่างนั้นหายไป หากตรรกะเกมของคุณสมมติว่าการไม่มีโฟกัสเท่ากับการไม่มีอินพุต คุณจะได้ "การกระทำผีหลอก" (phantom actions) เกิดขึ้น
ความแตกต่างนี้สำคัญอย่างยิ่งสำหรับกลไกการชาร์จ (charge-up mechanics) ที่พบได้ทั่วไป เช่น การง้างธนู, การเร่งเครื่องยานพาหนะ, การร่ายเวทมนตร์ที่ต้องชาร์จ หรือการวิ่งสปรินต์ที่ต้องใช้พลังงาน การกระทำที่ต่อเนื่องซึ่งสะสมสถานะตามเวลาจะมีความเสี่ยงต่อการตีความผิดพลาดแบบเดียวกัน แอปพลิเคชันแบบ Native มักจะหยุดการจำลอง (simulation) ทั้งหมดเมื่อเสียโฟกัส เกมบนเบราว์เซอร์สามารถทำแบบเดียวกันได้ แต่ถึงแม้คุณจะปล่อยให้เกมรันต่อไป คุณก็ต้องแยกการขัดจังหวะของระบบออกจากคำสั่งของผู้เล่น
การแยกเจตนาออกจากความขัดจังหวะ
การแก้ไขจำเป็นต้องแยกเส้นทางการออกจากสถานะการชาร์จออกเป็นสองทางที่ชัดเจน ทางหนึ่งจัดการกับอินพุตที่ตั้งใจ อีกทางหนึ่งจัดการกับการประคองสถานะเมื่อโลกภายนอกเข้ามาแทรกแซง
การปล่อยปุ่มโดยตั้งใจ—pointerup และ keyup—ยังคงสั่งให้กระโดด นี่คือสัญญาณโดยตรงจากผู้เล่นเพื่อเริ่มเคลื่อนที่
เหตุการณ์การเสียโฟกัส—blur, pointercancel, และ visibilitychange เมื่อเอกสารถูกซ่อน—จะไปกระตุ้นฟังก์ชันแยกต่างหากที่ชื่อว่า cancelCharge
cancelCharge ไม่ใช่การปล่อยปุ่มที่ถูกดัดแปลง แต่มันคือการรีเซ็ตแบบเบ็ดเสร็จ (hard reset) มันจะล้างแรงชาร์จที่สะสมไว้กลับเป็นศูนย์, คืนขนาดภาพของตัวละครกลับสู่สถานะปกติ (idle), รีเซ็ตเกจชาร์จบนหน้าจอ และคืนค่าเกมกลับสู่โหมดเล็ง ที่สำคัญที่สุดคือ มันจะไม่ไปแตะต้องโค้ดวิถีการพุ่ง (launch trajectory) เลย ไม่มีการคำนวณความเร็ว ไม่มีการส่งแรงทางฟิสิกส์ (physics impulse) และไม่มีการกระโดด พลังชาร์จจะระเหยหายไปอย่างปลอดภัย
โครงสร้างการเชื่อมต่อที่อัปเดตแล้วจะมีลักษณะเชิงแนวคิดดังนี้:
window.addEventListener("blur", cancelCharge);
แต่การเปลี่ยนแปลงทางสถาปัตยกรรมที่แท้จริงคือการตระหนักว่าการชาร์จคือสถานะ (state) ที่มีทางออกที่เป็นไปได้สองทาง ในการปล่อยปุ่มที่ถูกต้อง State machine จะประเมินเปอร์เซ็นต์การชาร์จ, คำนวณความเร็วการกระโดด และเปลี่ยนผ่านเข้าสู่แอนิเมชันการพุ่ง แต่หากมีการขัดจังหวะ State machine จะยกเลิกและกลับไปสู่สถานะ idle การแยกเส้นทางเหล่านี้ออกจากกันจะช่วยป้องกันผลกระทบข้างเคียง (side effects) ที่ไม่พึงประสงค์
คุณควรฟังเหตุการณ์ pointercancel ด้วย เบราว์เซอร์จะส่งเหตุการณ์นี้เมื่อตรวจพบการขัดจังหวะในระดับระบบบนอุปกรณ์ชี้ตำแหน่ง เช่น ท่าทางแบบ palm rejection บนหน้าจอสัมผัส, การเรียกใช้เมนูระบบ หรือปากกาที่หลุดจากการสัมผัสภายใต้สภาวะที่ไม่ปกติ การใช้ blur ควบคู่กับ pointercancel จะครอบคลุมทั้งการทำงานหลายอย่างพร้อมกันบนเดสก์ท็อปและการขัดจังหวะบนมือถือ นอกจากนี้ การเพิ่ม visibilitychange จะช่วยดักจับสถานการณ์ที่ผู้ใช้สลับแท็บโดยที่อาจไม่ได้ทำให้เกิด blur บน window object เอง ซึ่งสามารถเกิดขึ้นได้ในเบราว์เซอร์และระบบปฏิบัติการบางรุ่น
การทดสอบเงื่อนไขขอบเขต
การแก้ไขบั๊กด้านอินพุตจำเป็นต้องมีการทดสอบนอกเหนือจากเส้นทางปกติ (happy path) ไม่มีใครพบปัญหาเหล่านี้จากการเล่นเกมอย่างใจเย็นในแท็บเดียว เพื่อตรวจสอบพฤติกรรมใหม่ ผมได้ทดสอบสองสถานการณ์เฉพาะเจาะจง
อย่างแรก ผมเริ่มชาร์จการกระโดดแล้วบังคับให้เกิดเหตุการณ์ blur โดยการสลับแท็บเบราว์เซอร์ด้วยคีย์บอร์ด เกมหยุดโหมดชาร์จทันทีและกลับไปสู่โหมดเล็ง ไม่มีการกระโดดเกิดขึ้น ไม่มีการใช้ความเร็ว และเกจชาร์จก็ถูกล้างค่าออกไปเอง อย่างที่สอง ผมทำการชาร์จแบบปกติและปล่อยปุ่มอย่างตั้งใจ การกระโดดทำงานได้เหมือนเดิมทุกประการ ทั้งวิถีโค้งและสเกลของแรง ความรู้สึกในการเล่น (game feel) ยังคงเดิม มีเพียงกรณีขอบเขต (edge case) เท่านั้นที่ได้รับการแก้ไข
ทั้งสองเส้นทางต้องทำงานเป็นอิสระต่อกัน การแก้ไขที่ป้องกันการกระโดดโดยไม่ตั้งใจแต่ทำให้การกระโดดที่ตั้งใจจริงนั้นด้อยประสิทธิภาพลง ไม่ใช่การแก้ไข แต่มันคือบั๊กใหม่ เป้าหมายคือการรักษาความแม่นยำของกลไกเดิม ในขณะที่ทำให้มันทนทานต่อความวุ่นวายของเบราว์เซอร์
รูปแบบสำหรับการรับอินพุตแบบต่อเนื่อง
ปัญหานี้ขยายขอบเขตไปไกลกว่าแค่เกมแนวแพลตฟอร์มเมอร์ เกม Three.js ใดๆ ที่ต้องอาศัยการกดค้างจะมีความเสี่ยงนี้ ลองนึกถึงเกมมุมมองบุคคลที่หนึ่งที่มีตะขอเกี่ยว (grappling hook) ซึ่งการกดเมาส์ค้างจะช่วยสะสมแรงตึง หรือเกมแข่งรถที่การกดคีย์ค้างจะช่วยชาร์จการเร่งความเร็ว (boost) หากตรรกะการยกเลิก (teardown logic) ของคุณอยู่ในตัวจัดการการปล่อยปุ่ม (button release handler) เพียงอย่างเดียว และคุณไม่ได้คำนึงถึงการสลับแท็บ, การแจ้งเตือนของ OS หรือการล็อกหน้าจอ คุณกำลังปล่อยให้ระบบปฏิบัติการเล่นเกมแทนคุณ
รูปแบบที่กว้างกว่าคือการสร้างเลเยอร์อินพุตของคุณด้วยสามสถานะที่ชัดเจน: active input, released input และ cancelled input โดย active input คือการสะสมการชาร์จหรือเริ่มการกระทำ, released input คือการยืนยันการกระทำนั้น และ cancelled input คือการยกเลิกอย่างสะอาดหมดจด อย่าปล่อยให้ window blur ปลอมตัวเป็น release เบราว์เซอร์คือโฮสต์ ไม่ใช่ผู้เล่น
คำนึงถึงพฤติกรรมของมนุษย์
ผู้คนสลับแท็บ พวกเขาตอบข้อความส่วนตัว พวกเขาดูคู่มือบนหน้าจอที่สอง หรือได้รับการแจ้งเตือนจาก Slack เรื่องงาน สิ่งเหล่านี้ไม่ใช่กรณีขอบเขต แต่เป็นพฤติกรรมมาตรฐานภายในเบราว์เซอร์ เกมบนเบราว์เซอร์ที่ลงโทษการทำงานหลายอย่างพร้อมกันตามปกติของมนุษย์จะให้ความรู้สึกที่เปราะบาง ด้วยการปฏิบัติกับการสูญเสียโฟกัส (focus loss) ว่าเป็นการยกเลิกแทนที่จะเป็นคำสั่ง Solstice Leap จึงช่วยให้ผู้เล่นละสายตาไปชั่วครู่ได้โดยไม่ต้องเสียการกระโดดที่ตั้งใจชาร์จไว้อย่างดี
เหตุการณ์ blur ไม่ใช่เหตุการณ์ release มันเป็นเพียงการที่เบราว์เซอร์บอกว่ามันเดินออกจากห้องไป เขียนโค้ดให้สอดคล้องกับสิ่งนี้ แล้วผู้เล่นของคุณจะเชื่อมั่นในการควบคุมมากพอที่จะกระโดดเมื่อพวกเขาต้องการจริงๆ
