Jogadores odeiam perder uma partida por uma questão técnica. A plataforma estava clara, o tempo estava certo, e então o jogo os matou não porque cometeram um erro, mas porque a aba do navegador perdeu o foco.
Eu vi isso de perto no Solstice Leap, um jogo arcade em Three.js que construí em torno de uma única mecânica satisfatória: segurar um botão para carregar um salto e depois soltá-lo para saltar sobre lacunas. Durante os testes de jogo, notei um padrão irritante. Se alguém desse um Alt-Tab para responder a uma mensagem ou clicasse em outra aba enquanto o carregamento estava em andamento, o personagem se lançava no vazio no momento em que a janela voltava ao foco — ou, às vezes, imediatamente após a perda de foco. O jogo havia interpretado uma interrupção rotineira do sistema operacional como uma liberação intencional do botão. As partidas terminavam de forma injusta. A confiança nos controles diminuía.
A Causa Raiz: Um Evento Fazendo Dois Trabalhos
O bug era sutil, mas direto. Na camada de entrada original, o código anexava a lógica de liberação do salto diretamente ao evento blur da janela:
window.addEventListener("blur", releaseCharge);
Isso parece razoável se você olhar de relance. O jogador estava segurando uma tecla ou ponteiro; agora algo parou. Mas um evento blur não é um evento de entrada. É um sinal de gerenciamento de janela. Ele é disparado quando a aba do navegador perde o foco do sistema operacional, o que pode acontecer quando o jogador alterna entre abas, minimiza a janela, clica em um monitor externo ou até mesmo quando uma notificação do sistema rouba o foco. Nenhuma dessas ações significa "Eu quero lançar meu personagem". Elas significam "Estou interagindo com algo fora do jogo".
Ao rotear o blur para releaseCharge, o jogo confundia dois conceitos completamente diferentes: uma parada intencional (o jogador solta o botão) e uma interrupção externa (o navegador não é mais a janela ativa). Como o releaseCharge calculava a força do salto com base no estado atual do carregamento e aplicava a velocidade imediatamente, qualquer perda de foco durante o carregamento disparava um lançamento com qualquer potência que tivesse sido acumulada. O jogador retornava para encontrar seu personagem morto ou seu progresso arruinado por um movimento que ele nunca autorizou.
Realidades do Navegador para Desenvolvedores Three.js
O Three.js oferece um canvas 3D poderoso, mas a entrada ainda flui através do DOM. Essa divisão é importante. O navegador não sabe inerentemente que segurar a barra de espaço carrega um salto. Ele apenas sabe que uma tecla foi pressionada. Quando o foco sai do documento, o navegador não sintetiza automaticamente um keyup para cada tecla segurada. Em vez disso, ele avisa que a janela não está mais ativa. Se a lógica do seu jogo assumir que a ausência de foco equivale à ausência de entrada, você terá ações fantasmas.
Essa distinção é especialmente importante para mecânicas de carregamento, que aparecem em todos os lugares: puxar um arco, acelerar um veículo, lançar um feitiço carregado ou correr com um preparo de estamina. Qualquer ação sustentada que acumule estado ao longo do tempo é vulnerável à mesma interpretação errônea. Aplicações nativas geralmente pausam toda a simulação ao perder o foco. Jogos de navegador podem fazer o mesmo, mas mesmo que você continue executando, deve separar as interrupções do sistema dos comandos do jogador.
Separando Intenção de Interrupção
A correção exigiu a divisão do caminho de saída do estado de carregamento em duas vias distintas. Uma via lida com a entrada deliberada. A outra lida com o suporte de vida para quando o mundo real interfere.
Liberações deliberadas—pointerup e keyup—ainda executam o salto. Estes são os sinais diretos do jogador para agir.
Eventos de perda de foco—blur, pointercancel e visibilitychange quando o documento fica oculto—agora disparam uma função separada chamada cancelCharge.
O cancelCharge não é uma liberação modificada. É um reset total. Ele drena a força de carga acumulada de volta para zero, restaura a escala visual do jogador para seu estado de repouso padrão, zera o medidor de carga na tela e retorna o jogo ao modo de mira. O mais importante é que ele não toca no código de trajetória de lançamento. Não há cálculo de velocidade, nem impulso físico, nem salto. A carga evapora com segurança.
A nova estrutura lógica se parece conceitualmente com isto:
window.addEventListener("blur", cancelCharge);
Mas a verdadeira mudança arquitetônica é o reconhecimento de que o carregamento é agora um estado com duas saídas possíveis. Em uma liberação adequada, a máquina de estados avalia a porcentagem de carga, calcula a velocidade do salto e transita para a animação de salto. Em uma interrupção, a máquina de estados aborta e retorna ao estado de repouso. Manter esses caminhos separados evita efeitos colaterais.
Você também deve escutar o pointercancel. O navegador dispara esse evento quando detecta uma interrupção em nível de sistema no dispositivo de apontamento — como um gesto de rejeição de palma em telas sensíveis ao toque, uma invocação de menu do sistema ou uma caneta perdendo o contato sob condições incomuns. Combinar o blur com o pointercancel cobre tanto a multitarefa de desktop quanto as interrupções móveis. Adicionar o visibilitychange captura o cenário em que um usuário alterna entre abas sem necessariamente disparar o blur no próprio objeto da janela, o que pode acontecer em algumas combinações de navegador e sistema operacional.
Testando as Condições de Limite
Corrigir bugs de entrada exige testar além do "caminho feliz". Ninguém encontra esses problemas jogando calmamente em uma única aba. Para verificar o novo comportamento, executei dois cenários específicos.
Primeiro, comecei a carregar um pulo e, em seguida, forcei um evento de blur alternando as abas do navegador usando o teclado. O jogo saiu imediatamente do modo de carregamento e voltou para a mira. Nenhum pulo foi executado. Nenhuma velocidade foi aplicada. O medidor de carga se limpou sozinho. Segundo, realizei um carregamento normal e soltei o botão intencionalmente. O pulo foi executado exatamente como antes, com o mesmo arco e escala de força. A sensação do jogo permaneceu intacta; apenas o caso de borda foi corrigido.
Ambos os caminhos precisavam permanecer independentes. Uma correção que evita pulos acidentais, mas torna os legítimos menos precisos, não é uma correção — é um bug diferente. Preservar a precisão da mecânica original enquanto a tornava mais robusta contra o caos do navegador era o objetivo.
Um Padrão para Entrada Contínua
Este problema se estende muito além dos jogos de plataforma. Qualquer jogo em Three.js que dependa de um pressionamento contínuo está exposto. Considere um gancho de escalada em primeira pessoa onde segurar o mouse aumenta a tensão, ou um jogo de corrida onde uma tecla pressionada carrega um boost. Se a sua lógica de encerramento reside apenas em um manipulador de liberação de botão, e você não leva em conta a troca de abas, notificações do sistema operacional ou bloqueios de tela, você está permitindo que o sistema operacional jogue o seu jogo por você.
O padrão mais amplo é construir sua camada de entrada com três estados explícitos: entrada ativa, entrada liberada e entrada cancelada. A entrada ativa carrega ou inicia a ação. A entrada liberada a confirma. A entrada cancelada a interrompe de forma limpa. Nunca deixe um blur de janela se passar por uma liberação. O navegador é um hospedeiro, não um jogador.
Tenha o Comportamento Humano em Mente
As pessoas alternam abas. Elas respondem mensagens diretas. Elas consultam um guia em seu segundo monitor. Elas recebem notificações do Slack no trabalho. Esses não são casos de borda; são comportamentos padrão dentro de um navegador. Um jogo de navegador que pune a multitarefa normal do ser humano parece frágil. Ao tratar a perda de foco como um cancelamento, em vez de um comando, o Solstice Leap agora permite que os jogadores se afastem por um segundo sem sacrificar um pulo cuidadosamente preparado.
Um evento de blur não é um evento de liberação. É simplesmente o navegador dizendo que saiu da sala. Codifique de acordo, e seus jogadores confiarão nos controles o suficiente para dar o salto quando realmente pretendem fazê-lo.
