Pular para o conteúdo
← Voltar para o blog
Arquitetura de jogos em React Native

Como modelar eventos de conclusão de tabuleiro para feedback em jogos React Native

10 min de leitura

Escrito por: Jonathan Reis em

Uma linha concluída e o conjunto das nove ocorrências de um dígito podem merecer o mesmo feedback sem serem o mesmo evento de jogo. Tratar os dois como um evento cria comportamento visual errado e dificulta testar as regras.

Imagem de prévia OpenGraph deste artigo. Como modelar eventos de conclusão de tabuleiro para feedback em jogos React Native

Em um Sudoku, o jogador pode terminar uma linha, uma coluna, um bloco 3x3 ou as nove ocorrências de um mesmo dígito. Todos esses momentos podem transmitir progresso. Eles não representam a mesma transição de estado.

Essa diferença apareceu no Daily Sudoku quando o pedido foi tocar o som forte de conclusão ao posicionar corretamente o nono 1, 2 e assim por diante. O jogo já tocava esse som ao fechar linha, coluna ou bloco. Reaproveitar o som existente fazia sentido. Reaproveitar o evento de unidade concluída não fazia.

Se o nono 1 fosse tratado como linha ou bloco concluído, o renderer tentaria animar uma unidade que não mudou. A solução mais útil foi criar um evento semântico pequeno, preservar os eventos visuais de unidade e deixar áudio e haptics mapearem os dois eventos para o mesmo efeito.

Comece nomeando a transição de estado

A primeira pergunta de implementação não é “qual som deve tocar?”. É “o que aconteceu no tabuleiro?”.

O helper de unidade concluída inspeciona a linha, coluna e bloco tocados pela célula posicionada. Ele compara o tabuleiro antes e depois da jogada e devolve somente as unidades que mudaram de não resolvidas para resolvidas:

export function getNewlyCompletedUnits(input: {
  previousBoard: Board81;
  nextBoard: Board81;
  solution: Board81;
  placedIndex: CellIndex;
}): readonly CompletedUnit[] {
  // Inspect only the row, column, and block touched by the move.
}

Essa é uma regra espacial. Ela responde qual das três regiões do tabuleiro foi concluída por aquela célula.

Completar todas as cópias de um dígito é outra regra. Ela precisa do valor colocado, da confirmação de que a jogada bate com a solução e da transição exata de menos de nove para nove:

export function isDigitNewlyCompleted(input: {
  previousBoard: Board81;
  nextBoard: Board81;
  solution: Board81;
  placedIndex: CellIndex;
}): boolean {
  const value = input.nextBoard[input.placedIndex];
  if (value === 0 || value !== input.solution[input.placedIndex]) return false;

  return countDigit(input.previousBoard, value) < 9 && countDigit(input.nextBoard, value) === 9;
}

A checagem de acerto não é detalhe visual. Dependendo das regras do produto, uma UI de Sudoku pode aceitar temporariamente um número incorreto para mostrar o erro. Contar uma nona ocorrência incorreta e celebrá-la transforma o feedback em informação falsa.

A condição do tabuleiro anterior também importa. count(nextBoard) === 9 sozinho não detecta um evento; ele consulta estado. Retornaria true de novo sempre que o mesmo tabuleiro já completo fosse inspecionado. Comparar snapshots torna o evento sensível à borda da transição: acontece uma vez, no momento da mudança.

Separe eventos visuais de eventos de feedback

O jogo já enviava eventos de animação por uma fila. unitCompleted leva placedIndex e as unidades concluídas porque os renderers precisam saber quais células animar.

type GameAnimationEvent =
  | {
      id: string;
      type: "unitCompleted";
      placedIndex: CellIndex;
      units: readonly CompletedUnit[];
    }
  | {
      id: string;
      type: "digitCompleted";
      digit: CellValue;
    };

digitCompleted propositalmente não tem lista de células. Ele é um evento semântico de feedback, não uma instrução para animar linha, coluna ou bloco. O mapeador de presets de animação o ignora, enquanto o mapeador de áudio o consome.

Isso evita um atalho comum: adicionar uma flag como isUnitCompleted ao evento de célula posicionada e pedir para cada consumidor inferir o que aquilo quer dizer. O atalho funciona até um consumidor precisar de animação de bloco, outro de som e outro de haptic. Um evento distinto obriga cada consumidor a lidar apenas com o que consegue renderizar.

Mapeie vários eventos para um efeito

Arquivos de som são escolhas de implementação. Eventos de gameplay são fatos do produto. A camada de áudio pode mapear os dois fatos para o mesmo efeito:

for (const event of events) {
  switch (event.type) {
    case "unitCompleted":
    case "digitCompleted":
      pushUnique(effects, "unitCompleted");
      break;
    case "correctCellPlaced":
      pushUnique(effects, "correct");
      break;
  }
}

if (effects.includes("unitCompleted")) {
  return effects.filter((effect) => effect !== "correct");
}

A última condição define a precedência de feedback. A jogada que coloca o nono dígito continua sendo um acerto, mas o sinal de conclusão substitui o som comum de acerto. Sem essa regra, o jogador ouve dois efeitos para uma ação e o evento forte perde significado.

O Daily Sudoku encaminha haptics pela mesma classificação semântica de eventos, mantendo uma configuração separada para vibração. O handler de jogada relata eventos; providers decidem se som ou vibração estão ligados e qual API de plataforma pode executar. Assim, regras de jogo não se misturam com capacidade do aparelho nem preferência do usuário.

Com o Expo Audio, isso também evita criar outro player ou asset. A fonte, volume, cooldown e fallback Web já existentes para unitCompleted continuam como implementação única desse efeito.

Emita o evento em todo caminho de jogada

A regressão sutil não estava no predicado. Estava na quantidade de caminhos que podem posicionar um valor correto.

O Daily Sudoku tem três caminhos relevantes:

  • posicionamento normal do jogador;
  • assistente de auto-complete, que aplica uma sequência de singles determinísticos;
  • atalho de auto-solve de desenvolvimento para verificação local.

Cada um tem o tabuleiro anterior, o posterior, a solução e o índice colocado. Cada um emite digitCompleted logo depois de aplicar uma jogada correta. O evento passa a acompanhar a transição de estado, não a interação da tela que a disparou.

Não esconda essa checagem apenas no handler de um botão. Ela some para teclado, automação, dicas, replay ou próximos assistentes que mudem o tabuleiro por outra rota. Se o app tem um reducer ou uma fronteira de comandos para toda jogada, esse é o lugar melhor. Neste código, os caminhos de movimento já montavam eventos de animação localmente, então o predicado compartilhado mantém a orquestração duplicada pequena e testável.

Teste a transição, não o player de áudio

O teste de domínio útil começa com uma solução válida, remove uma ocorrência do dígito, a restaura e espera true. Um segundo teste troca a célula removida por outro dígito e espera false.

expect(
  isDigitNewlyCompleted({
    previousBoard,
    nextBoard,
    solution,
    placedIndex,
  }),
).toBe(true);

O teste de áudio pode ser menor. Passe um evento correctCellPlaced e um digitCompleted para o mapeador e confirme que o resultado contém apenas unitCompleted. Não é necessário criar um player de áudio para provar a regra de precedência.

Rodamos os arquivos Vitest focados no domínio e no áudio, depois typecheck do Sudoku, Expo lint, Prettier e git diff --check. Isso cobre o contrato e a integração estática. Não prova volume ou timing percebido em cada aparelho físico, então o smoke de release ainda deve incluir uma conclusão manual de dígito.

Checklist curto de implementação

  • Defina cada conclusão como uma transição nomeada de estado, não uma condição do renderer.
  • Compare snapshots anterior e posterior para o evento disparar uma vez.
  • Confirme o valor colocado contra a solução antes de recompensar a jogada.
  • Separe eventos com payload visual de eventos que só carregam feedback.
  • Mapeie momentos equivalentes de feedback para um efeito na camada de áudio.
  • Suprima o efeito normal de acerto quando existir o efeito forte de conclusão.
  • Emita o evento em todos os caminhos que conseguem posicionar valores no tabuleiro.
  • Teste transições corretas e incorretas sem depender da reprodução nativa.

A regra operacional é simples: compartilhe resultados quando eles forem realmente compartilhados, não os eventos que por acaso os produziram. Linha concluída e conjunto de dígitos completo podem soar como progresso. Os payloads ainda devem descrever fatos diferentes sobre o tabuleiro.

Post relacionadoAdicionando haptics do Expo a um jogo React Native sem tratar vibração como som8 min de leituraEscrito por: Jonathan Reis em