Gracze nienawidzą przegrywać rozgrywki przez kwestie techniczne. Platforma była stabilna, wyczucie czasu idealne, a potem gra ich zabijała nie dlatego, że popełnili błąd, ale dlatego, że karta przeglądarki straciła fokus.
Widziałem to na własne oczy w Solstice Leap, grze arcade stworzonej w Three.js, którą zbudowałem wokół jednej satysfakcjonującej mechaniki: przytrzymaj przycisk, aby naładować skok, a następnie puść go, aby przeskoczyć przez szczeliny. Podczas testów zauważyłem irytujący wzorzec. Jeśli ktoś użył Alt-Tab, aby odpisać na wiadomość, lub kliknął inną kartę podczas ładowania, postać rzucała się w przepaść w momencie powrotu do okna — lub czasem natychmiast po utracie fokusu. Gra zinterpretowała rutynowe przerwanie systemu operacyjnego jako celowe puszczenie przycisku. Rozgrywki kończyły się niesprawiedliwie. Zaufanie do sterowania spadało.
Przyczyna źródłowa: Jedno zdarzenie wykonujące dwie funkcje
Błąd był subtelny, ale bezpośredni. W oryginalnej warstwie wejściowej kod przypisywał logikę puszczenia skoku bezpośrednio do zdarzenia blur okna:
window.addEventListener("blur", releaseCharge);
Jeśli przymkniesz oko, wydaje się to rozsądne. Gracz trzymał klawisz lub wskaźnik; teraz coś się zatrzymało. Ale zdarzenie blur nie jest zdarzeniem wejściowym (input event). To sygnał zarządzania oknem. Wyzwala się, gdy karta przeglądarki traci fokus systemu operacyjnego, co może się stać, gdy gracz przełącza karty, minimalizuje okno, klika na zewnętrznym monitorze lub gdy powiadomienie systemowe przejmuje fokus. Żadna z tych czynności nie oznacza „chcę wystrzelić moją postać”. Oznaczają one „interaguję z czymś poza grą”.
Przekierowując blur do releaseCharge, gra połączyła dwa zupełnie różne pojęcia: celowe zatrzymanie (gracz puszcza przycisk) i zewnętrzne przerwanie (przeglądarka nie jest już aktywnym oknem). Ponieważ releaseCharge obliczało siłę skoku na podstawie aktualnego stanu ładowania i natychmiast nakładało prędkość, każda utrata fokusu w trakcie ładowania powodowała skok z jakąkolwiek nagromadzoną mocą. Gracz wracał, by zastać swoją postać martwą lub postęp zrujnowany ruchem, na który nigdy nie wyraził zgody.
Rzeczywistość przeglądarkowa dla deweloperów Three.js
Three.js daje potężne płótno 3D, ale wejście (input) wciąż przepływa przez DOM. To rozdzielenie ma znaczenie. Przeglądarka sama w sobie nie wie, że przytrzymanie spacji ładuje skok. Wie tylko, że klawisz został wciśnięty. Gdy fokus opuszcza dokument, przeglądarka nie generuje automatycznie zdarzenia keyup dla każdego przytrzymywanego klawisza. Zamiast tego informuje, że okno zniknęło. Jeśli logika gry zakłada, że brak fokusu równa się brakowi wejścia, otrzymasz „widmowe” akcje.
To rozróżnienie jest szczególnie ważne dla mechanik ładowania, które pojawiają się wszędzie: naciąganie łuku, przyspieszanie pojazdu, rzucanie zaklęcia obszarowego czy sprint z nabieraniem rozpędu. Każda podtrzymywana akcja, która kumuluje stan w czasie, jest podatna na to samo błędne zinterpretowanie. Natywne aplikacje często wstrzymują całą symulację przy utracie fokusu. Gry przeglądarkowe mogą robić to samo, ale nawet jeśli gra działa dalej, musisz oddzielić przerwania systemowe od poleceń gracza.
Rozdzielenie intencji od przerwania
Rozwiązanie wymagało rozdzielenia ścieżki wyjścia ze stanu ładowania na dwie odrębne drogi. Jedna obsługuje celowe wejście. Druga obsługuje „podtrzymywanie życia” na wypadek, gdyby świat rzeczywisty wkroczył do gry.
Celowe puszczenia — pointerup i keyup — nadal wykonują skok. Są to bezpośrednie sygnały gracza do działania.
Zdarzenia utraty fokusu — blur, pointercancel oraz visibilitychange (gdy dokument staje się ukryty) — wyzwalają teraz osobną funkcję o nazwie cancelCharge.
cancelCharge nie jest zmodyfikowanym puszczeniem przycisku. To twardy reset. Sprowadza nagromadzoną siłę ładowania do zera, przywraca wizualną skalę gracza do domyślnego stanu spoczynku, zeruje licznik ładowania na ekranie i przywraca grę do trybu celowania. Co najważniejsze, nie dotyka kodu trajektorii skoku. Nie ma obliczeń prędkości, impulsu fizycznego ani skoku. Ładowanie bezpiecznie wyparowuje.
Zaktualizowana logika połączeń koncepcyjnie wygląda następująco:
window.addEventListener("blur", cancelCharge);
Jednak prawdziwa zmiana architektoniczna polega na uznaniu, że ładowanie jest teraz stanem z dwoma możliwymi wyjściami. Przy prawidłowym puszczeniu przycisku maszyna stanów ocenia procent ładowania, oblicza prędkość skoku i przechodzi do animacji skoku. W przypadku przerwania maszyna stanów przerywa działanie i wraca do stanu idle. Utrzymanie tych ścieżek oddzielnie zapobiega efektom ubocznym.
Powinieneś również nasłuchiwać zdarzenia pointercancel. Przeglądarka wysyła je, gdy wykryje przerwanie na poziomie systemu w urządzeniu wskazującym — takie jak gest odrzucania dłoni (palm rejection) na ekranach dotykowych, wywołanie menu systemowego lub utrata kontaktu przez rysik w nietypowych warunkach. Połączenie blur z pointercancel obejmuje zarówno wielozadaniowość na komputerach stacjonarnych, jak i przerwy na urządzeniach mobilnych. Dodanie visibilitychange pozwala wyłapać scenariusz, w którym użytkownik przełącza karty, niekoniecznie wywołując blur na samym obiekcie window, co może się zdarzyć w niektórych kombinacjach przeglądarki i systemu operacyjnego.
Testowanie warunków brzegowych
Naprawianie błędów związanych z wprowadzaniem danych wymaga testowania poza tzw. „happy path” (ścieżką optymistyczną). Nikt nie znajdzie tych problemów, spokojnie grając w grę w jednej karcie. Aby zweryfikować nowe zachowanie, przeprowadziłem dwa konkretne scenariusze.
Po pierwsze, zacząłem ładować skok, a następnie wymusiłem zdarzenie blur, przełączając karty przeglądarki za pomocą klawiatury. Gra natychmiast wyszła z trybu ładowania i wróciła do celowania. Skok nie został wykonany. Żadna prędkość nie została zastosowana. Wskaźnik ładowania sam się wyczyścił. Po drugie, wykonałem normalne ładowanie i celowo puściłem przycisk. Skok wykonał się dokładnie tak, jak wcześniej, z tą samą trajektorią i skalowaniem siły. Odczucie z gry (game feel) pozostało nienaruszone; załatano jedynie przypadek brzegowy.
Oba ścieżki musiały pozostać niezależne. Poprawka, która zapobiega przypadkowym skokom, ale osłabia te zamierzone, nie jest poprawką — jest innym błędem. Celem było zachowanie precyzji oryginalnej mechaniki przy jednoczesnym zabezpieczeniu jej przed chaosem przeglądarki.
Wzorzec dla ciągłego wprowadzania danych
Ten problem wykracza daleko poza platformówki. Każda gra w Three.js, która polega na ciągłym przytrzymaniu przycisku, jest na to narażona. Rozważmy hak z perspektywy pierwszej osoby, gdzie przytrzymanie myszy buduje napięcie, lub grę wyścigową, w której przytrzymany klawisz ładuje boosta. Jeśli Twoja logika czyszczenia (teardown) znajduje się tylko w handlerze puszczenia przycisku i nie uwzględniasz przełączania kart, powiadomień systemu operacyjnego lub blokad ekranu, pozwalasz systemowi operacyjnemu grać w Twoją grę za Ciebie.
Szerszym wzorcem jest zbudowanie warstwy wejściowej z trzema wyraźnymi stanami: wejście aktywne (active input), wejście zwolnione (released input) i wejście anulowane (cancelled input). Wejście aktywne buduje ładunek lub inicjuje akcję. Wejście zwolnione ją zatwierdza. Wejście anulowane czysto ją przerywa. Nigdy nie pozwól, aby blur okna udawało zwolnienie przycisku. Przeglądarka jest gospodarzem, a nie graczem.
Pamiętaj o ludzkich zachowaniach
Ludzie przełączają karty. Odpowiadają na wiadomości prywatne. Szukają poradnika na drugim monitorze. Otrzymują powiadomienia ze Slacka w pracy. To nie są przypadki brzegowe; to standardowe zachowanie w przeglądarce. Gra przeglądarkowa, która karze za normalną ludzką wielozadaniowość, wydaje się krucha. Traktując utratę fokusu jako anulowanie, a nie komendę, Solstice Leap pozwala teraz graczom odejść na sekundę bez poświęcania starannie przygotowanego skoku.
Zdarzenie blur nie jest zdarzeniem zwolnienia przycisku. To po prostu przeglądarka mówiąca, że wyszła z pokoju. Koduj w ten sposób, a gracze będą ufać sterowaniu na tyle, by wykonać skok wtedy, gdy naprawdę tego chcą.
