Como modelar eventos de conclusão de tabuleiro para feedback em jogos React Native
10 min de leituraEscrito 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.

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