Flow: Criando um Jogo da Velha, 2 de 2

Finalizando nosso jogo com exemplo completo!

Eduardo Rabelo
9 min readNov 23, 2017
Parte 2 de 2, nosso exemplo completo!

Por que faz sentido usar FlowType ou TypeScript ao trabalhar com JavaScript? Uma boa maneira de responder essa questão é criar um pequeno jogo ou aplicativo para deixar os benefícios claros.

Essa é a parte 2/2 sobre como criar um Jogo da Velha com FlowType, você pode ver o primeiro artigo clicando aqui.

Refatorando

É hora de refatorar nosso único componente e criar um componente Board e Cell.

A refatoração não tem nada de especial, o foco é uma melhor manutenção. Nossos componentes serão estruturados da seguinte maneira:

const Cell = ({ cell: CellType }) => {
return <div style={{
float: 'left',
textAlign: 'center',
border: '1px solid #eee',
padding: '75px'
}}>
cell
</div>
}
const Board = ({ board: BoardType }) : React$Element<any> => {
return <div>
{board.map((row, i) => {
return <div style={{width: '600px', height: '150px'}} key={i}>
{row.map((cell, j) => <Cell key={j} cell={cell} /> )}
</div>
})}
</div>
}
class TicTacToe extends React.Component<*, State> {
state = {
board: board,
status: {type: 'Running'},
player: 0,
}
render() {
const {board} = this.state
return <div>{
<Board board={board} />
}</div>
}
}

Avançando com as funcionalidades

Então quebramos nosso aplicativo em vários componentes e fizemos uma pequena renomeação para evitar alguns conflitos de nomes. Renomeamos o tipo Board para BoardType e Cell para CellType.

Agora que reestruturamos manualmente nosso jogo, é hora de avançar com as tarefas mais interessantes. Ainda estamos apresentando Cell na tela. Mas o que realmente queremos fazer é renderizar a representação correta, ou seja, um círculo ou um xís.

Como sabemos sobre o tipo que será passado, podemos exibir a representação visual apropriada. Vamos escrever uma pequena função que receba a Cell e retorna uma string:

const displayCell = (cell: CellType) : string => {
switch(cell.type) {
case ' Circle': return 'O'
case 'Cross': return 'X'
default: return ''
}
}

Nós podemos testar rapidamente nossa função displayCell para verificar se funciona como esperado:

console.log('X' === displayCell({type: 'Cross'}));

Aqui é como nosso componente Cell se parece:

type CellProps = {
cell: CellType,
}
const Cell = ({cell} : CellProps) => {
return <div style={{
float: 'left',
textAlign: 'center',
border: '1px solid #eee',
padding: '75px'
}}>
{displayCell(cell)}
</div>
}

Nossa próxima tarefa é adicionar interatividade, caso contrário o jogo não faz sentido. Vamos também recapitular o que realmente precisamos fazer:

  • O usuário pode clicar em uma célula, se a célula estiver vazia, renderizamos um círculo ou um xís.
  • Toda vez que uma célula é atualizada, os usuários mudam.
  • Se uma linha ou uma coluna ou uma diagonal tiverem o mesmo tipo (circulo ou xís), há um vencedor e o jogo termina.
  • Se todas as células estiverem preenchidas e não há nenhum vencedor até este ponto, então o jogo é um empate.

O que podemos ver é que existem várias combinações possíveis que precisamos acompanhar.

Para começar a fazer essas combinações, iremos nos concentrar na parte de mudança do jogador.

const switchPlayer = (player: Player) : Player => {
switch(player) {
case 0: return 1
case 1: return 0
default: return 0
}
}

Passamos um Player e retornamos um Player. Continuando, precisamos implementar uma função de atualização, que irá atualizar uma célula. Então, como pode resolver isso de forma consistente?

Nós temos o tipo Board, que é modelado em uma estrutura 3x3, isso significa que se quisermos atualizar a célula superior esquerda, poderíamos acessá-la através do índice board[0][0] e a célula inferior direita via board[2][2]. Uma outra maneira seria transformar o tabuleiro 3x3 em uma lista plana.

// Helper functions
type ToFlatList = (list: BoardType) => Array<CellType>;
const toFlatList: ToFlatList = list =>
list.reduce((xs, x) => {
return xs.concat(x);
}, []);
type ToBoard = (Array<CellType>) => BoardType;
const toBoard: ToBoard = ([c1, c2, c3, c4, c5, c6, c7, c8, c9]) => {
return [[c1, c2, c3], [c4, c5, c6], [c7, c8, c9]];
};

Vamos aproveitar essas duas funções e transformar os dados quando necessário. Por exemplo, agora podemos atualizar uma célula apenas sabendo seu índice. Isso simplificará as coisas de forma significativa. Claro que também poderíamos tomar a outra rota, principalmente tendo que definir um tipo de coluna, e trocar a linha e depois a coluna, mas isso pode vir com algumas despesas consideráveis. Nossa implementação atual deve ser adequada. Vamos implementar uma função que atualiza uma célula.

const updateCell = (
board: BoardType,
player: Player,
index: number
): BoardType => {
const cells = toFlatList(board);
const cell = cells[index];
if (cell && cell.type === "Empty") {
const updatedCell: Circle | Cross =
player === 0 ? { type: "Cross" } : { type: "Circle" };
return toBoard([
...cells.slice(0, index),
updatedCell,
...cells.slice(index + 1)
]);
}
return board;
};

Nós convertemos o Board que foi passado para uma lista plana e acessamos o índice passado. Se o tipo de célula for Empty, atualizamos a célula com o tipo certo dependendo do jogador definido. Não há mágica envolvida aqui. Apenas uma função que sempre retorna um Board atualizado ou o Board que foi passado, se uma atualização não for possível. Além disso, podemos testar facilmente esta função, mas deixaremos isso como uma tarefa para o leitor interessado.

Nosso próximo passo é criar uma função que é acionada quando o jogador clica na célula. Além disso, devemos ter em mente que, se um jogador clicar em uma célula preenchida, nada deve acontecer.

const isCell = (board: BoardType, index: number): boolean => {
const list = toFlatList(board);
return list[index] !== undefined;
};

A função isCell verifica se a célula atual existe no Board, que será chamada no processo de atualização do estado atual. Somente quando for válido, nós iremos chamar setState com o Board e Player atualizados. Iremos adicionar um método setCell na nossa classe TicTacToe e passar este método para o nosso componente Cell, que deve ser suficiente para exibir o estado correto da célula.

class TicTacToe extends React.Component<*, State> {
...
setCell = (index: number) : void => {
this.setState(state => {
const {board, player} = state
return isCell(board, index)
? {
player: player === 0 ? 1 : 0,
board: updateCell(board, player, index),
}
: {}
})
}
render() {
const {board} = this.state
return <div>{
<Board board={board} updateCell={this.setCell} />
}</div>
}
}

Agora, tudo que precisamos fazer, é passar o método recém-definido através do componente Board para Cell. Um aspecto importante a ser observado é que estamos calculando o índice de células na linha onClick={() => updateCell(i*3 + j). Como mencionado anteriormente, também podemos alterar a implementação e definir tipos de coluna, acessando as células via board[0][0]. Mas deixaremos isso como uma tarefa para o leitor interessado.

type BoardProps = {
board: BoardType,
updateCell: (i: number) => void
}
const Board = ({board, updateCell} : BoardProps) : React$Element<any> => {
return <div>
{board.map((row, i) => {
return <div style={{width: '600px', height: '150px'}} key={i}>
{row.map((cell: CellType, j) =>
<Cell key={j} cell={cell} onClick={() => updateCell(i*3 + j)}/>
)}
</div>
})}
</div>
}

Finalmente, nosso componente Cell chama a função via onClick. Aqui está o nosso componente Cell atualizado, incluindo algumas mudanças de estilo menores:

type CellProps = {
cell: CellType,
onClick: () => void,
}
const Cell = ({cell, onClick} : CellProps) => {
return <div
style={{
float: 'left',
textAlign: 'center',
fontSize: '3em',
border: '1px solid #eee',
height: '150px',
width: '150px',
textAlign: 'center',
verticalAlign: '50%',
lineHeight: '150px',
}}
onClick={onClick}
>
{displayCell(cell)}
</div>
}

Agora, clicando em uma célula, ela irá ser atualizada, caso esteja vazia.

Estamos nos aproximando da finalização desse jogo. O que resta fazer? Até agora, não sabemos se o jogo terminou ou se houve um vencedor. Para verificar se o jogo acabou, podemos verificar se há células Empty restantes:

type IsFinished = (board: BoardType) => boolean;
const isFinished: IsFinished = board =>
toFlatList(board).reduce((xs, x) => xs && x.type !== "Empty", true);

Tudo o que precisamos fazer é reduzir a lista e verificar se há uma célula vazia.

Continuando com a parte de validação: queremos saber se uma linha, ou uma coluna ou uma diagonal contém o mesmo tipo, sendo uma cruz ou um xís.

Como escolhemos converter entre uma estrutura 3x3 e a lista plana, podemos converter qualquer combinação de índices de uma Row.

Vamos definir as combinações possíveis que precisamos verificar:

const row1 = [0, 1, 2]
const row2 = [3, 4, 5]
const row3 = [6, 7, 8]
const col1 = [0, 3, 6]
const col2 = [1, 4, 7]
const col3 = [2, 5, 8]
const diag1 = [0, 4, 8]
const diag2 = [2, 4, 6]
const rows = [row1, row2, row3, col1, col2, col3, diag1, diag2]

Agora temos todas as combinações possíveis. Tudo convertido em linha, não importa se é uma linha real ou não.

Em seguida, precisamos de uma função que possa escolher qualquer valor de uma determinada lista e retornar uma Row.

const pick = (selection: Array<number>, board: BoardType): Maybe<Row> => {
const flatlist: Array<CellType> = toFlatList(board);
const result = selection.reduce((xs, x) => {
const cell: ?CellType = flatlist[x];
return cell ? [...xs, cell] : xs;
}, []);
if (result.length === 3) {
const [c1, c2, c3] = result;
return { type: "Just", result: [c1, c2, c3] };
}
return { type: "Nothing" };
};

Nossa função pick irá cuidar de retornar uma Row. Uma vez que temos uma Row, podemos validar as células, verificando se elas são do mesmo tipo:

const validate = (row: Maybe<Row>) : Player | null => {
if (row.type === 'Nothing') return null
const [one, two, three] = row.result
if ((one.type === two.type) && (one.type === three.type)) {
return one.type === 'Cross' ? 0 : one.type === 'Circle' ? 1 : null
}
return null
}

Não há muito o que dizer sobre nossa função validate, exceto que devolvemos um jogador ou nulo como resultado. O que significa que podemos saber qual jogador ganhou, verificando se o mesmo tipo é uma cruz ou um circulo e mapeando-o de volta para o jogador.

Para encerrar tudo isso, precisamos conectar nossa função validate com as combinações possíveis de linhas, que definimos anteriormente. Podemos escrever uma função isWinner que aceita o tabuleiro e executa todas as combinações possíveis com validate. Assim que tivermos uma Row válida, também temos um vencedor. Tecnicamente, reduzir todas as combinações de linhas deve ser o suficiente. Ao retornar um jogador e a linha ganhadora, podemos exibir mais as informações na tela.

type IsWinner = (board: BoardType) => [Player, Row] | false;
const isWinner: IsWinner = (board, player) => {
const row1 = [0, 1, 2];
const row2 = [3, 4, 5];
const row3 = [6, 7, 8];
const col1 = [0, 3, 6];
const col2 = [1, 4, 7];
const col3 = [2, 5, 8];
const diag1 = [0, 4, 8];
const diag2 = [2, 4, 6];
const rows: Array<Array<number>> = [
row1,
row2,
row3,
col1,
col2,
col3,
diag1,
diag2
];
return rows.reduce((selected, selection) => {
if (selected) return selected;
const row: Maybe<Row> = pick(selection, board);
if (row.type === "Nothing") return selected;
const winner = validate(row);
if (winner !== null) {
return [winner, row.result];
}
return false;
}, false);
};

Para finalizar, precisamos chamar isFinished e isWinner nos lugares corretos. Vamos atualizar nossa implementação anterior do método setCell.

setCell = (index: number): void => {
this.setState(state => {
const { board, player } = state;
if (!isCell(board, index)) return {};
const updatedBoard = updateCell(board, player, index);
const winner = isWinner(updatedBoard);
if (winner) {
return {
board: updatedBoard,
status: { type: "Just", result: winner }
};
} else if (isFinished(updatedBoard)) {
return {
board: updatedBoard,
status: { type: "Nothing" }
};
} else {
return {
board: updatedBoard,
player: switchPlayer(player)
};
}
});
};

Há muita coisa acontecendo aqui. Passamos por várias etapas: primeiro, verificamos se o movimento é válido. Se for válido, verificamos se temos um vencedor e, se não, verificamos se o jogo tem um vencedor. Você pode refatorar isso, ou mover as verificações de isWinner e isFinished para ComponentDidUpdate. Recomendo você experimentar diversas opções.

Agora nós temos um Jogo da Velha! Ainda há algumas melhorias para serem feitas, mas estão fora do escopo deste artigo. Se você está interessado em melhorar o jogo, aqui estão algumas idéias:

  • Evitar cliques após o término do jogo ou no caso de haver um vencedor.
  • Exibir o jogador atual.
  • Exibir o status do jogo.
  • Destacar a combinação vencedora.

Finalizando

É isso aí galera, um exemplo não tão convencional, para demonstrar como podemos implementar de maneira fácil FlowType em JavaScript.

Você pode conferir o exemplo complete no CodeSandbox ou através desse Gist.

Se você tiver dúvidas ou sugestões, por favor, adicione comentários ou me envie no Twitter.

Créditos

--

--