Sunstone Apps
← Voltar para o blog

Haptics em React Native

Adicionando haptics do Expo a um jogo React Native sem tratar vibração como som

Haptics parecem pequenos até virarem promessa de produto. A parte útil não é chamar a API de vibração; é decidir quando o app tem permissão para tocar no aparelho do usuário.

Publicado: 8 min de leitura
Imagem de prévia OpenGraph deste artigo. Adicionando haptics do Expo a um jogo React Native sem tratar vibração como som

Adicionar haptics a um jogo mobile parece uma tarefa técnica minúscula.

Instale expo-haptics, chame impactAsync() em uma jogada correta, chame notificationAsync() em um erro e siga em frente. Essa versão funciona para uma demo. Não é a versão que eu quero em uma configuração de jogo publicada.

A tarefa real no Daily Sudoku: Offline Puzzle era mais estreita e mais útil: adicionar uma configuração de vibração que se comportasse como controle de produto, não como chamadas espalhadas para uma API. O app já tinha efeitos sonoros para acertos, erros, unidades completas, auto-complete, vitórias, derrotas e recompensa diária. A vibração deveria acompanhar esses momentos, mas não virar outro nome para som.

Som e vibração são promessas diferentes para o usuário. Alguns jogadores desligam som porque estão em público. Outros desligam haptics porque não gostam de feedback físico ou querem economizar bateria. Outros deixam som desligado e vibração ligada porque esse é o aviso discreto que preferem. Uma implementação limpa precisa preservar essa escolha.

Comece pela configuração, não pela API

A primeira decisão foi o contrato de produto:

  • soundEnabled controla feedback audível;
  • vibrationEnabled controla feedback físico;
  • ambos começam ligados por padrão;
  • ambos persistem nas settings locais;
  • ambos aparecem em Configurações e nas settings dentro da partida;
  • nenhum dos dois muda regras congeladas de gameplay, como limite de erros.

Esse contrato importa porque o Sudoku já tem configurações que podem mudar durante uma partida ativa e configurações que não podem. O limite máximo de erros pertence às regras do início do jogo. Ele precisa ficar congelado depois que o puzzle começa. Vibração não altera score, derrota, persistência nem economia. Pode mudar ao vivo.

Então a configuração pertence à mesma fonte de verdade das settings:

export interface PersistedSettings {
  soundEnabled: boolean;
  vibrationEnabled: boolean;
  highlightSameNumbersEnabled: boolean;
}

A parte importante não é o boolean a mais. A parte importante é onde esse boolean é lido. Telas e renderers não precisam saber como settings são salvas, qual é o default ou se o SQLite já carregou. Eles precisam perguntar a um provider pequeno se a vibração está permitida naquele momento.

Coloque haptics ao lado do som, não dentro do som

O app já tinha um SoundProvider. Ele fica abaixo do SettingsProvider e expõe helpers de som de jogo para a árvore React. Haptics usam a mesma posição:

<SettingsProvider initial={bootstrap.settings}>
  <SoundProvider>
    <VibrationProvider>
      <ThemedAppContent />
    </VibrationProvider>
  </SoundProvider>
</SettingsProvider>

Esse formato é proposital. Vibração depende de settings, então precisa ficar abaixo de settings. Ela não deve ficar escondida dentro do provider de som porque o usuário pode desligar uma coisa sem desligar a outra. Manter um VibrationProvider separado deixa essa fronteira de produto visível no código.

Também deixa os pontos de uso pequenos:

const { vibrateFromEvents } = useVibrationManager();

vibrateFromEvents(animationEvents, { finishKind: soundFinishKind });

Um handler de jogada não decide se vibração está ligada. Ele não decide qual estilo de haptic representa erro. Ele só repassa os mesmos eventos de gameplay que o app já usa para feedback.

Reaproveite eventos de gameplay

O melhor atalho já existia no código. Som não era disparado por dezenas de chamadas soltas. Ele era derivado de eventos de animação do gameplay.

Isso deu aos haptics uma fonte de verdade estável. Jogada correta, erro, unidade completa, auto-complete, vitória, derrota e recompensa diária já passavam por um modelo de eventos. O gerenciador de vibração pode reaproveitar esse modelo:

function vibrateFromEvents(
  events: readonly GameAnimationEvent[],
  options?: { finishKind?: GameSoundFinishKind },
) {
  if (!vibrationEnabled) return;

  const effects = getGameSoundEffects(events, { finishKind: options?.finishKind });
  for (const effect of effects) {
    vibrateEffect(effect);
  }
}

O nome getGameSoundEffects pode parecer específico demais de áudio no começo, mas os valores retornados são momentos semânticos de feedback: correct, mistake, unitCompleted, autoComplete, win, loss e dailyReward. Para este app, são os mesmos momentos que merecem feedback físico.

Isso é diferente de dizer que som e vibração são a mesma configuração. Eles compartilham classificação de evento. Eles não compartilham a decisão de liga/desliga do usuário.

Não faça o Web pagar por haptics nativo

expo-haptics é um recurso nativo. O app de Sudoku também roda na Web. Um import estático normal colocaria o módulo no caminho de bundle Web mesmo quando a Web não deveria fazer nada.

O provider usa um require preguiçoso atrás de uma guarda de plataforma:

let hapticsModule: HapticsModule | null | undefined;

function getHapticsModule(): HapticsModule | null {
  if (hapticsModule !== undefined) return hapticsModule;
  if (Platform.OS === "web") {
    hapticsModule = null;
    return null;
  }

  try {
    hapticsModule = require("expo-haptics") as HapticsModule;
  } catch {
    hapticsModule = null;
  }

  return hapticsModule;
}

Não é um padrão para usar em qualquer lugar. Aqui ele é útil porque o comportamento de produto é claro: Web não tem haptics do app. O manager vira no-op silencioso, e o resto do jogo continua chamando a mesma interface.

O try/catch também protege setups de desenvolvimento em que o módulo nativo ainda não está linkado. Um módulo de haptics ausente não deve derrubar a casca do app. Deve significar apenas ausência de vibração.

Mapeie efeitos com contenção

Haptics podem ficar barulhentos rápido, mesmo sem som. Sudoku é repetitivo por natureza. Um jogador pode preencher dezenas de números em uma sessão curta, e o auto-complete pode disparar vários momentos de feedback perto um do outro. O mapeamento precisa ser perceptível sem transformar cada toque em um zumbido.

O app usa um mapa pequeno:

  • erros e derrotas usam feedback de notificação de erro;
  • vitórias, unidades completas, recompensa diária e auto-complete usam feedback de sucesso;
  • acertos usam impacto leve;
  • Web e ambientes sem suporte não fazem nada.

Isso é intencionalmente menos detalhado que a paleta de som. Som pode usar arquivos diferentes para eventos parecidos sem interromper fisicamente o jogador. Haptics são mais diretos. Um impacto leve para acerto basta. Feedback mais forte fica reservado para eventos que mudam o estado da sessão.

O provider também não tenta enfileirar ou aplicar debounce em todo efeito. O modelo de eventos de gameplay já evita tocar “acerto” e “unidade completa” ao mesmo tempo quando a unidade completa deve ter precedência. Reaproveitar essa lógica mantém a vibração alinhada à hierarquia de feedback que já existia.

A UI de settings ainda importa

O trabalho de UI não foi só adicionar uma linha. O app já tinha aprendido uma lição Android com o toggle de som: controles Switch interativos aninhados podem alternar duas vezes quando ficam dentro de uma linha pressionável. Linhas de settings que parecem switches usam um único alvo de toque com semântica de switch; o switch visual é passivo.

A linha de vibração segue esse padrão. Ela aparece nas settings normais e dentro da partida. Ela persiste no SQLite. Ela começa ligada por padrão. Ela pode ser alterada durante uma partida porque não muda as regras daquele jogo.

Isso mantém o recurso fácil de explicar:

  • “Som” controla feedback audível.
  • “Vibração” controla feedback físico.
  • “Destacar números iguais” controla uma ajuda visual.
  • “Auto-check” e timer continuam partes intencionais do jogo, não toggles opcionais.

A última linha importa. Nem toda configuração pedida deve virar configuração. Neste Sudoku, o destaque de erro em tempo real sustenta a regra de que erros importam e alimenta a economia de segunda chance. O timer é só display visível; o tempo interno já alimenta o score. Transformar qualquer um deles em toggle aumentaria a superfície sem melhorar o contrato de produto.

O que testar

O teste rápido não basta. Dá para chamar vibrate("correct") em um componente e sentir o telefone vibrar. Isso prova que o módulo está instalado. Não prova que o recurso está integrado.

Um checklist melhor:

  • começar com vibração ligada e fazer jogadas corretas e incorretas;
  • completar uma linha, coluna ou bloco e confirmar que o feedback de sucesso mais forte não dobra com o feedback normal de acerto;
  • disparar auto-complete e fluxo de vitória;
  • conceder recompensa diária depois de vencer;
  • desligar vibração nas settings e repetir as mesmas ações de gameplay;
  • manter som ligado com vibração desligada, depois inverter as configurações;
  • rodar o build Web e confirmar que o app não falha no caminho de import de haptics.

Essa matriz é pequena, mas pega os riscos reais: acoplar som e haptics sem querer, deixar um evento fora do provider, quebrar Web ou deixar uma mudança de settings alterar regras de gameplay.

A regra que eu manteria

Para jogos React Native, haptics normalmente devem ser um provider de feedback, não um monte de chamadas dentro de telas de feature. Coloque a decisão do usuário no mesmo sistema de settings do resto do app. Coloque a API de plataforma atrás de um manager pequeno. Alimente esse manager com eventos semânticos de gameplay, não com detalhes de UI de baixo nível.

Isso mantém o código honesto. Uma célula do tabuleiro não vibra porque foi pressionada. Ela vibra porque a jogada foi aceita, recusada, completou uma unidade ou encerrou o jogo. Essa distinção é o que faz o recurso sobreviver ao próximo renderer, à próxima tela de settings e ao build Web.