Oyuncular, teknik bir detay yüzünden bir seriyi (run) kaybetmekten nefret ederler. Platform netti, zamanlama doğruydu ve sonra oyun onları bir hata yaptıkları için değil, tarayıcı sekmesi odağını kaybettiği için öldürdü.
Bunu, tek bir tatmin edici mekanik üzerine inşa ettiğim Three.js tabanlı bir arcade oyunu olan Solstice Leap'te bizzat deneyimledim: zıplama gücü toplamak için bir düğmeye basılı tutun, ardından boşlukları aşmak için bırakın. Testler sırasında sinir bozucu bir kalıp fark ettim. Eğer biri bir mesaja cevap vermek için Alt-Tab yaparsa veya güç toplama aşamasındayken başka bir sekmeye tıklarsa, pencere tekrar odaklandığı anda karakter kendini boşluğa fırlatıyordu — ya da bazen odak kaybı yaşandığı anda. Oyun, rutin bir işletim sistemi kesintisini kasıtlı bir düğme bırakma eylemi olarak yorumlamıştı. Seriler haksız yere sona eriyor, kontrollere olan güven sarsılıyordu.
Kök Neden: İki İşlevi Tek Bir Olay Üstlenmek
Hata sinsi ama doğrudan bir hataydı. Orijinal girdi katmanında kod, zıplama bırakma mantığını doğrudan pencerenin blur olayına bağlamıştı:
window.addEventListener("blur", releaseCharge);
Biraz esnetince bu mantıklı görünebilir. Oyuncu bir tuşa veya imlece basılı tutuyordu; şimdi ise bir şey durdu. Ancak bir blur olayı bir girdi olayı değildir. Bu bir pencere yönetimi sinyalidir. Tarayıcı sekmesi işletim sistemi odağını kaybettiğinde tetiklenir; bu durum oyuncu sekmeler arası geçiş yaptığında, pencereyi küçülttüğünde, harici bir monitöre tıkladığında ve hatta bir sistem bildirimi odağı çaldığında gerçekleşebilir. Bu eylemlerin hiçbiri "karakterimi fırlatmak istiyorum" anlamına gelmez. Bunlar "oyun dışındaki bir şeyle etkileşime giriyorum" anlamına gelir.
blur olayını releaseCharge fonksiyonuna yönlendirerek oyun, iki tamamen farklı kavramı birbirine karıştırdı: kasıtlı bir duruş (oyuncunun düğmeyi bırakması) ve dış bir kesinti (tarayıcının artık aktif pencere olmaması). releaseCharge fonksiyonu, zıplama kuvvetini mevcut şarj durumuna göre hesaplayıp hızı anında uyguladığı için, şarjın ortasındaki herhangi bir odak kaybı, o ana kadar birikmiş olan güçle bir fırlatma tetikliyordu. Oyuncu geri döndüğünde, karakterinin öldüğünü veya ilerlemesinin hiç onaylamadığı bir hareketle mahvolduğunu görüyordu.
Three.js Geliştiricileri İçin Tarayıcı Gerçekleri
Three.js size güçlü bir 3D tuval sunar, ancak girdiler hâlâ DOM üzerinden akar. Bu ayrım önemlidir. Tarayıcı, boşluk tuşuna basılı tutmanın bir zıplama gücü topladığını doğası gereği bilmez. Sadece bir tuşa basıldığını bilir. Odak belgeden ayrıldığında, tarayıcı basılı tutulan her tuş için otomatik olarak bir keyup olayı üretmez. Bunun yerine size pencerenin artık orada olmadığını söyler. Eğer oyun mantığınız odağın yokluğunu girdinin yokluğuyla eş tutarsa, hayalet eylemlerle karşılaşırsınız.
Bu ayrım, her yerde karşımıza çıkan güç toplama mekanikleri için özellikle önemlidir: yay germek, bir aracı hızlandırmak, yüklü bir büyü yapmak veya dayanıklılık (stamina) biriktirerek koşmak. Zamanla bir durum (state) biriktiren her türlü sürekli eylem, aynı yanlış yorumlamaya karşı savunmasızdır. Yerel uygulamalar genellikle odak kaybında tüm simülasyonu duraklatır. Tarayıcı oyunları da aynısını yapabilir, ancak oyununuzu çalıştırmaya devam etseniz bile, sistem kesintilerini oyuncu komutlarından ayırmalısınız.
Niyeti Kesintiden Ayırmak
Çözüm, şarj durumundan çıkış yolunu iki ayrı kanala ayırmayı gerektiriyordu. Bir kanal kasıtlı girdileri yönetir; diğeri ise gerçek dünya araya girdiğinde devreye girecek bir destek mekanizması sağlar.
Kasıtlı bırakmalar—pointerup ve keyup—hâlâ zıplamayı gerçekleştirir. Bunlar oyuncunun doğrudan "git" sinyalleridir.
Odak kaybı olayları—blur, pointercancel ve belgenin gizlendiği durumlardaki visibilitychange—artık cancelCharge adlı ayrı bir fonksiyonu tetikler.
cancelCharge modifiye edilmiş bir bırakma işlemi değildir. Sert bir sıfırlamadır. Biriken şarj gücünü sıfıra indirir, oyuncunun görsel ölçeğini varsayılan bekleme durumuna geri döndürür, ekrandaki şarj göstergesini sıfırlar ve oyunu nişan alma moduna döndürür. En önemlisi, fırlatma yörüngesi koduna dokunmaz. Herhangi bir hız hesaplaması, fiziksel itki veya sıçrama gerçekleşmez. Şarj güvenli bir şekilde buharlaşır.
Güncellenmiş yapı kavramsal olarak şöyledir:
window.addEventListener("blur", cancelCharge);
Ancak asıl mimari değişiklik, şarj işleminin artık iki olası çıkışı olan bir "durum" (state) olduğunun kabul edilmesidir. Doğru bir bırakma işleminde durum makinesi (state machine) şarj yüzdesini değerlendirir, zıplama hızını hesaplar ve sıçrama animasyonuna geçer. Bir kesinti durumunda ise durum makinesi işlemi iptal eder ve bekleme (idle) durumuna döner. Bu yolları ayrı tutmak, istenmeyen yan etkileri önler.
pointercancel olayını da dinlemelisiniz. Tarayıcı, işaretleme aygıtında sistem düzeyinde bir kesinti algıladığında (dokunmatik ekranlardaki avuç içi reddetme hareketi, bir sistem menüsünün çağrılması veya bir kalemin olağandışı koşullar altında temasını kaybetmesi gibi) bunu tetikler. blur olayını pointercancel ile eşleştirmek, hem masaüstü çoklu görevlerini hem de mobil kesintileri kapsar. visibilitychange eklemek ise, bazı tarayıcı ve işletim sistemi kombinasyonlarında olabildiği gibi, kullanıcının pencere nesnesi üzerinde mutlaka blur tetiklemeden sekmeler arası geçiş yaptığı senaryoları yakalar.
Sınır Koşullarını Test Etmek
Giriş hatalarını düzeltmek, "mutlu yol"un (happy path) dışına çıkıp test yapmayı gerektirir. Kimse bu sorunları tek bir sekmede sakince oyun oynayarak bulamaz. Yeni davranışı doğrulamak için iki özel senaryo çalıştırdım.
İlk olarak, bir zıplama şarjı başlattım ve ardından klavyeyi kullanarak tarayıcı sekmelerini değiştirerek bir blur olayı zorladım. Oyun anında şarj modundan çıktı ve nişan alma moduna geri döndü. Zıplama gerçekleşmedi. Hız uygulanmadı. Şarj göstergesi kendini temizledi. İkinci olarak, normal bir şarj gerçekleştirdim ve düğmeyi kasıtlı olarak bıraktım. Zıplama, tıpkı daha önce olduğu gibi aynı yay ve kuvvet ölçeklendirmesiyle gerçekleşti. Oyun hissi bozulmadı; sadece uç durum (edge case) yamalandı.
Her iki yol da birbirinden bağımsız kalmalıydı. Kazara zıplamaları önleyen ancak meşru olanları körelten bir düzeltme, bir düzeltme değil; farklı bir hatadır. Hedef, orijinal mekaniğin keskinliğini korurken onu tarayıcı kaosuna karşı güçlendirmekti.
Sürekli Giriş İçin Bir Desen
Bu sorun platform oyunlarının çok ötesine uzanıyor. Sürekli basılı tutmaya dayanan herhangi bir Three.js oyunu bu riske açıktır. Fareyi basılı tutmanın gerilimi artırdığı bir birinci şahıs kanca (grappling hook) mekaniğini veya basılı tutulan bir tuşun hız artışı (boost) sağladığı bir yarış oyununu düşünün. Eğer sonlandırma mantığınız yalnızca bir düğme bırakma işleyicisinde (handler) yaşıyorsa ve sekme değiştirme, işletim sistemi bildirimleri veya ekran kilitlenmeleri gibi durumları hesaba katmıyorsanız, işletim sisteminin oyununuzu sizin yerinize oynamasına izin veriyorsunuz demektir.
Daha geniş kapsamlı desen, giriş katmanınızı üç açık durumla oluşturmaktır: aktif giriş, bırakılan giriş ve iptal edilen giriş. Aktif giriş şarjı oluşturur veya eylemi başlatır. Bırakılan giriş onu onaylar. İptal edilen giriş ise onu temiz bir şekilde sonlandırır. Bir pencere blur olayının asla bir bırakma (release) olayı gibi davranmasına izin vermeyin. Tarayıcı bir ev sahibidir, oyuncu değil.
İnsan Davranışlarını Göz Önünde Bulundurun
İnsanlar sekmeler arası geçiş yapar. Doğrudan mesajlara cevap verirler. İkinci monitörlerinden bir kılavuza bakarlar. İş için Slack bildirimleri alırlar. Bunlar uç durumlar değil; bir tarayıcı içindeki standart davranışlardır. Normal insan çoklu görev yeteneğini cezalandıran bir tarayıcı oyunu kırılgan hissettirir. Solstice Leap, odak kaybını bir komut yerine bir iptal olarak ele alarak, oyuncuların özenle ayarlanmış bir zıplamayı feda etmeden bir saniyeliğine uzaklaşmalarına olanak tanıyor.
Bir blur olayı bir bırakma olayı değildir. Bu sadece tarayıcının odadan çıktığını söylemesidir. Buna göre kod yazın; böylece oyuncularınız, gerçekten istediklerinde o sıçrayışı gerçekleştirmek için kontrollere yeterince güveneceklerdir.
