Người chơi cực kỳ ghét việc bị mất một lượt chơi chỉ vì một lỗi kỹ thuật nhỏ. Nền tảng đã rõ ràng, thời điểm đã chuẩn xác, nhưng rồi trò chơi lại khiến họ thất bại không phải vì họ mắc lỗi, mà vì tab trình duyệt bị mất tiêu điểm (focus).
Tôi đã trực tiếp chứng kiến điều này trong Solstice Leap, một trò chơi arcade sử dụng Three.js mà tôi xây dựng xoay quanh một cơ chế duy nhất đầy thú vị: giữ một nút để tích lực nhảy, sau đó thả ra để phóng qua các khoảng trống. Trong quá trình chơi thử, tôi nhận thấy một quy luật gây ức chế. Nếu ai đó nhấn Alt-Tab để trả lời tin nhắn hoặc nhấp vào một tab khác trong khi lực đang tích, nhân vật sẽ lao thẳng vào hư vô ngay khi cửa sổ được nhấp trở lại—hoặc đôi khi là ngay lập tức khi mất tiêu điểm. Trò chơi đã diễn giải một sự gián đoạn thông thường của hệ điều hành thành một thao tác thả nút có chủ đích. Các lượt chơi kết thúc một cách bất công. Niềm tin vào khả năng điều khiển bị xói mòn.
Nguyên nhân gốc rễ: Một sự kiện đảm nhận hai vai trò
Lỗi này tuy tinh vi nhưng lại rất trực tiếp. Trong lớp xử lý đầu vào (input layer) ban đầu, mã nguồn đã gắn logic thả nhảy trực tiếp vào sự kiện blur của cửa sổ:
window.addEventListener("blur", releaseCharge);
Nghe có vẻ hợp lý nếu bạn nhìn lướt qua. Người chơi đang giữ một phím hoặc con trỏ; và giờ có thứ gì đó vừa dừng lại. Nhưng một sự kiện blur không phải là một sự kiện đầu vào (input event). Nó là một tín hiệu quản lý cửa sổ. Nó kích hoạt khi tab trình duyệt mất tiêu điểm của hệ điều hành, điều này có thể xảy ra khi người chơi chuyển tab, thu nhỏ cửa sổ, nhấp vào một màn hình bên ngoài, hoặc thậm chí khi một thông báo hệ thống chiếm quyền điều khiển. Không có hành động nào trong số đó có nghĩa là "Tôi muốn phóng nhân vật của mình". Chúng có nghĩa là "Tôi đang tương tác với thứ gì đó bên ngoài trò chơi".
Bằng cách điều hướng blur vào releaseCharge, trò chơi đã đánh đồng hai khái niệm hoàn toàn khác nhau: một sự dừng lại có chủ đích (người chơi thả nút) và một sự gián đoạn bên ngoài (trình duyệt không còn là cửa sổ đang hoạt động). Vì releaseCharge tính toán lực nhảy dựa trên trạng thái tích lực hiện tại và áp dụng vận tốc ngay lập tức, nên bất kỳ sự mất tiêu điểm nào trong khi đang tích lực cũng sẽ kích hoạt một cú phóng với bất kỳ mức năng lượng nào đã tích lũy được. Người chơi quay lại và thấy nhân vật của mình đã chết hoặc tiến trình bị hủy hoại bởi một hành động mà họ chưa bao giờ cho phép.
Thực tế trình duyệt đối với các nhà phát triển Three.js
Three.js cung cấp cho bạn một canvas 3D mạnh mẽ, nhưng đầu vào vẫn chảy qua DOM. Sự phân tách đó rất quan trọng. Trình duyệt không tự hiểu rằng việc giữ phím cách (spacebar) là để tích lực nhảy. Nó chỉ biết rằng một phím đang được nhấn. Khi tiêu điểm rời khỏi tài liệu (document), trình duyệt không tự động tạo ra một sự kiện keyup cho mọi phím đang được giữ. Thay vào đó, nó báo cho bạn biết rằng cửa sổ đã biến mất. Nếu logic trò chơi của bạn giả định rằng việc thiếu tiêu điểm đồng nghĩa với việc thiếu đầu vào, bạn sẽ gặp phải các hành động "ma" (phantom actions).
Sự phân biệt này đặc biệt quan trọng đối với các cơ chế tích lực (charge-up), vốn xuất hiện ở khắp mọi nơi: giương cung, rồ ga xe, tung một phép thuật tích năng lượng, hoặc chạy nước rút với sự tích tụ thể lực. Bất kỳ hành động duy trì nào tích lũy trạng thái theo thời gian đều dễ bị diễn giải sai tương tự. Các ứng dụng gốc (native applications) thường tạm dừng toàn bộ mô phỏng khi mất tiêu điểm. Trò chơi trên trình duyệt cũng có thể làm như vậy, nhưng ngay cả khi bạn vẫn để trò chơi chạy, bạn phải tách biệt các gián đoạn hệ thống khỏi các lệnh của người chơi.
Tách biệt Ý định khỏi Sự gián đoạn
Cách khắc phục yêu cầu chia lộ trình thoát khỏi trạng thái tích lực thành hai làn đường riêng biệt. Một làn xử lý đầu vào có chủ đích. Làn còn lại xử lý việc "duy trì sự sống" khi thế giới thực xâm nhập.
Các thao tác thả có chủ đích—pointerup và keyup—vẫn thực hiện cú nhảy. Đây là những tín hiệu trực tiếp của người chơi để bắt đầu.
Các sự kiện mất tiêu điểm—blur, pointercancel, và visibilitychange khi tài liệu bị ẩn—giờ đây sẽ kích hoạt một hàm riêng biệt gọi là cancelCharge.
cancelCharge không phải là một thao tác thả được sửa đổi. Nó là một lệnh thiết lập lại hoàn toàn (hard reset). Nó rút hết lực tích lũy về mức không, khôi phục tỷ lệ hiển thị của người chơi về trạng thái nghỉ mặc định, đưa thanh tích lực trên màn hình về không, và đưa trò chơi trở lại chế độ ngắm. Quan trọng nhất là nó không chạm vào mã quỹ đạo phóng. Không có tính toán vận tốc, không có xung lực vật lý, và không có cú nhảy nào cả. Lực tích lũy sẽ tan biến một cách an toàn.
Cách kết nối được cập nhật về mặt khái niệm trông như thế này:
window.addEventListener("blur", cancelCharge);
Nhưng thay đổi thực sự về mặt kiến trúc là việc nhận ra rằng quá trình tích lực giờ đây là một trạng thái với hai lối thoát có thể xảy ra. Khi có thao tác thả đúng cách, máy trạng thái (state machine) sẽ đánh giá tỷ lệ phần trăm tích lực, tính toán vận tốc nhảy và chuyển sang hoạt ảnh nhảy. Khi có sự gián đoạn, máy trạng thái sẽ hủy bỏ và quay trở lại trạng thái nghỉ. Việc giữ các lộ trình này tách biệt sẽ ngăn chặn các tác dụng phụ.
Bạn cũng nên lắng nghe sự kiện pointercancel. Trình duyệt sẽ kích hoạt sự kiện này khi phát hiện sự gián đoạn ở cấp độ hệ thống trên thiết bị trỏ—chẳng hạn như cử chỉ loại bỏ lòng bàn tay (palm rejection) trên màn hình cảm ứng, việc gọi menu hệ thống, hoặc bút bị mất tiếp xúc trong những điều kiện bất thường. Việc kết hợp blur với pointercancel sẽ bao quát được cả việc đa nhiệm trên máy tính để bàn lẫn các sự gián đoạn trên thiết bị di động. Thêm vào đó, visibilitychange sẽ giúp bắt được kịch bản người dùng chuyển tab mà không nhất thiết phải kích hoạt blur trên chính đối tượng window, điều có thể xảy ra ở một số sự kết hợp giữa trình duyệt và hệ điều hành.
Kiểm tra các điều kiện biên
Việc sửa các lỗi nhập liệu đòi hỏi phải kiểm tra bên ngoài luồng xử lý thông thường (happy path). Không ai tìm thấy những vấn đề này bằng cách chơi game một cách bình thản trong một tab duy nhất. Để xác minh hành vi mới, tôi đã chạy hai kịch bản cụ thể.
Đầu tiên, tôi bắt đầu tích lực nhảy và sau đó ép buộc một sự kiện blur bằng cách chuyển tab trình duyệt bằng bàn phím. Trò chơi ngay lập tức thoát khỏi chế độ tích lực và quay lại trạng thái ngắm. Không có cú nhảy nào được thực hiện. Không có vận tốc nào được áp dụng. Thanh tích lực tự xóa sạch. Thứ hai, tôi thực hiện một cú tích lực bình thường và chủ động thả nút. Cú nhảy được thực hiện chính xác như trước đó, với cùng một quỹ đạo và tỷ lệ lực. Cảm giác game vẫn được giữ nguyên; chỉ có trường hợp biên là được vá lỗi.
Cả hai lộ trình này đều phải hoạt động độc lập. Một bản sửa lỗi ngăn chặn các cú nhảy vô ý nhưng lại làm giảm đi các cú nhảy hợp lệ thì không phải là một bản sửa lỗi—đó là một lỗi khác. Mục tiêu là giữ được sự nhạy bén của cơ chế gốc trong khi tăng cường khả năng chống lại sự hỗn loạn của trình duyệt.
Một khuôn mẫu cho đầu vào duy trì
Vấn đề này mở rộng ra xa hơn nhiều so với các trò chơi đi cảnh (platformers). Bất kỳ trò chơi Three.js nào dựa vào việc nhấn giữ liên tục đều gặp rủi ro. Hãy cân nhắc một trò chơi móc kéo góc nhìn thứ nhất, nơi việc giữ chuột sẽ tích lực, hoặc một trò chơi đua xe nơi việc giữ phím sẽ tích tụ tăng tốc. Nếu logic hủy bỏ (teardown logic) của bạn chỉ nằm trong trình xử lý khi thả nút, và bạn không tính đến việc chuyển tab, thông báo hệ điều hành hoặc khóa màn hình, bạn đang cho phép hệ điều hành chơi game thay cho mình.
Khuôn mẫu rộng hơn là xây dựng lớp đầu vào (input layer) của bạn với ba trạng thái rõ ràng: đầu vào đang hoạt động (active input), đầu vào đã thả (released input), và đầu vào bị hủy (cancelled input). Đầu vào đang hoạt động sẽ tích lực hoặc bắt đầu hành động. Đầu vào đã thả sẽ thực thi nó. Đầu vào bị hủy sẽ dừng nó một cách gọn gàng. Đừng bao giờ để một sự kiện blur của cửa sổ giả dạng như một sự kiện thả nút. Trình duyệt là vật chủ, không phải người chơi.
Luôn lưu ý đến hành vi của con người
Mọi người thường chuyển tab. Họ trả lời tin nhắn trực tiếp. Họ tra cứu hướng dẫn trên màn hình thứ hai. Họ nhận thông báo Slack từ công việc. Đây không phải là các trường hợp biên; đó là hành vi tiêu chuẩn khi sử dụng trình duyệt. Một trò chơi trên trình duyệt mà trừng phạt việc đa nhiệm bình thường của con người sẽ tạo cảm giác thiếu ổn định. Bằng cách coi việc mất tiêu điểm (focus loss) là một sự hủy bỏ thay vì một lệnh thực thi, Solstice Leap giờ đây cho phép người chơi rời đi trong giây lát mà không làm mất đi một cú nhảy đã được thiết lập kỹ lưỡng.
Một sự kiện blur không phải là một sự kiện thả nút. Nó chỉ đơn giản là trình duyệt nói rằng nó đã bước ra khỏi phòng. Hãy lập trình tương ứng, và người chơi của bạn sẽ đủ tin tưởng vào các nút điều khiển để thực hiện cú nhảy khi họ thực sự muốn.
