Tres en raya con React y TypeScript
La lógica, los componentes y la persistencia de un tres en raya hecho con React, Tailwind, TypeScript y Framer Motion.
El tres en raya es un juego sencillo que todos conocemos. En este artículo exploraremos lo más importante sobre la lógica, la funcionalidad y la interfaz de este juego. Se utilizaron tecnologías como React, Tailwind, TypeScript y Framer Motion. Es fundamental tener en cuenta que este proyecto se inició utilizando la herramienta Vite.
Estructura de carpetas del proyecto
Tomando la estructura que nos proporciona Vite al crear un proyecto, se organizó en distintas carpetas, lo que dio como resultado la siguiente estructura.
public/
└── favicon.svg
src/
├── components/
├── consts/
├── hooks/
├── localStorage/
├── logic/
├── types/
├── App.scss
├── App.tsx
├── index.css
├── main.tsx
└── vite-env.d.ts
index.html
... Archivos de configuración generados por Vite.Constantes
Para el desarrollo de la lógica y la funcionalidad hay varias constantes importantes:
OPTIONS: las opciones de juego, X y O.WINNING_COMBO: los combos ganadores del juego. Para representar el tablero vamos a utilizar un arreglo, y cada número será la posición de la opción X u O.
export const OPTIONS = {
X: 'X',
O: 'O',
} as const
export const WINNING_COMBO = [
[0, 3, 6],
[1, 4, 7],
[2, 5, 8],
[0, 1, 2],
[3, 4, 5],
[6, 7, 8],
[0, 4, 8],
[2, 4, 6],
] as constComponentes importantes y lógica
Cada ícono que aparece en cada casilla del tablero será un componente <SquareIcon />. Este recibe como prop el turno turn en el que va el juego, es decir, una de las OPTIONS de las constantes anteriores. El componente renderiza un SVG de Framer Motion según el turno, para que esté animado.
import { m } from 'framer-motion'
import { type SquareOptions } from '../types/types'
import { OPTIONS } from '../consts/consts'
// Configuración para animar el SVG
const draw = {
hidden: { pathLength: 0, opacity: 0 },
visible: {
pathLength: 1,
opacity: 1,
transition: {
pathLength: {
type: 'spring',
duration: 0.5,
bounce: 0,
},
opacity: {
duration: 0.01,
},
},
},
}
interface Props {
turn: SquareOptions
}
const SquareIcon: React.FC<Props> = ({ turn }): JSX.Element => {
if (turn === OPTIONS.O) {
return (
<m.svg
width="100%"
height="100%"
viewBox="-15 -15 230 230"
initial="hidden"
animate="visible"
>
<m.circle
cx="100"
cy="100"
r="80"
stroke="#3b82f6"
strokeWidth="6"
fill="transparent"
variants={draw}
/>
</m.svg>
)
}
if (turn === OPTIONS.X) {
return (
<m.svg
width="100%"
height="100%"
viewBox="-40 -40 280 280"
initial="hidden"
animate="visible"
>
<m.line x1="0" y1="0" x2="200" y2="200" stroke="#ef4444" strokeWidth="6" variants={draw} />
<m.line x1="200" y1="0" x2="0" y2="200" stroke="#ef4444" strokeWidth="6" variants={draw} />
</m.svg>
)
}
// En caso de que no se pase una opción, retornamos un Fragment
return <></>
}
export default SquareIconAhora, cada casilla será un componente <Square />. Este componente recibe un children que será una SquareOptions (X, O o null), un index que será la posición de esa casilla en el tablero, y una función onClick que se ejecutará cuando ocurra el evento click; a esta función se le pasará el index como parámetro.
import { type SquareOptions } from '../types/types'
import SquareIcon from './SquareIcon'
import { AnimatePresence } from 'framer-motion'
interface Props {
children: SquareOptions // X, O o null
index: number
onClick: (index: number) => void
}
const Square: React.FC<Props> = ({ children, index, onClick }): JSX.Element => {
return (
<div
className="flex h-[100px] w-[100px] cursor-pointer items-center justify-center text-6xl font-semibold transition-colors sm:h-[120px] sm:w-[120px] md:hover:bg-slate-900"
onClick={() => onClick(index)}
>
{/* Componente de Framer Motion para animar el componente dentro de él */}
<AnimatePresence>
<SquareIcon turn={children} />
</AnimatePresence>
</div>
)
}
export default SquareLa funcionalidad general del juego se separó en dos partes: la lógica, y la parte del cliente, donde se implementaron diversos hooks.
Para verificar quién es el ganador de la partida utilizamos la siguiente lógica. Cabe aclarar que board es un arreglo de nueve casillas que pueden tener los valores X, O o null.
import { WINNING_COMBO } from '../consts/consts'
import { type SquareOptions } from '../types/types'
export const verifyWinner = (board: SquareOptions[]): boolean => {
for (let i = 0; WINNING_COMBO.length > i; i++) {
const [a, b, c] = WINNING_COMBO[i]
if (board[a] !== null && board[a] === board[b] && board[c] === board[b]) {
return true
}
}
return false
}Para guardar la partida se puede utilizar el localStorage, y lo implementamos de la siguiente manera.
import { type TurnType, type SquareOptions } from '../types/types'
export const setStorage = (label: string, objectToSave: SquareOptions[] | TurnType): void => {
localStorage.setItem(label, JSON.stringify(objectToSave))
}
export const getStorage = (label: string): SquareOptions[] | TurnType => {
return JSON.parse(localStorage.getItem(label) as string)
}Teniendo en cuenta la lógica y la funcionalidad que se crearon anteriormente, se hizo un hook personalizado para manejar la aplicación.
import { useState } from 'react'
import { OPTIONS } from '../consts/consts'
import { verifyWinner } from '../logic/logic'
import { type SquareOptions, type TurnType } from '../types/types'
import { getStorage, setStorage } from '../localStorage/localStorage'
interface ReturnType {
turn: TurnType
resetBoard: () => void
board: SquareOptions[]
winner: TurnType
handleClick: (index: number) => void
winnerPopup: boolean
}
export const useBoard = (): ReturnType => {
const [winner, setWinner] = useState<TurnType>(OPTIONS.X)
const [winnerPopup, setWinnerPopup] = useState<boolean>(false)
const [turn, setTurn] = useState<TurnType>(() => {
const turn = getStorage('turn')
if (turn !== null) {
return turn as TurnType
}
return OPTIONS.X
})
const [board, setBoard] = useState<SquareOptions[]>(() => {
// Cargamos la partida que esté guardada en el localStorage
const storage = getStorage('board')
if (storage !== null) {
return storage as SquareOptions[]
}
return new Array(9).fill(null)
})
const handleClick = (index: number): void => {
const square = board[index]
if (square === null && !winnerPopup) {
const newTurn = turn === OPTIONS.X ? OPTIONS.O : OPTIONS.X
const newBoard = [...board]
newBoard[index] = turn
setStorage('board', newBoard)
setStorage('turn', newTurn)
setBoard(newBoard)
setTurn(newTurn)
validateWinner(newBoard)
}
}
const resetBoard = (): void => {
setTurn(OPTIONS.X)
const newBoard = new Array(9).fill(null)
setBoard(newBoard)
setStorage('board', newBoard)
setStorage('turn', OPTIONS.X)
}
// Validar el ganador de la partida y mostrarle un popup
const validateWinner = (newBoard: SquareOptions[]): void => {
if (verifyWinner(newBoard)) {
setWinner(turn)
setWinnerPopup(true)
setTimeout(() => {
setWinnerPopup(false)
resetBoard()
}, 3000)
}
}
return {
resetBoard,
board,
winner,
handleClick,
winnerPopup,
turn,
}
}Componente principal
El componente principal, <Board />, donde estará el tablero de juego, quedaría de la siguiente manera.
import Square from './Square'
import Footer from './Footer'
import WinnerPopup from './WinnerPopup'
import { useBoard } from '../hooks/useBoard'
import { AnimatePresence } from 'framer-motion'
import LineWinner from './LineWinner'
const Board = (): JSX.Element => {
const { board, handleClick, resetBoard, winner, winnerPopup, turn } = useBoard()
return (
<>
<AnimatePresence>{winnerPopup && <WinnerPopup winner={winner} />}</AnimatePresence>
<main className="board relative mt-10 grid grid-cols-3 grid-rows-3">
{board.map((value, index) => {
return (
<Square key={index} index={index} onClick={handleClick}>
{value}
</Square>
)
})}
</main>
<Footer turn={turn} resetBoard={resetBoard} />
</>
)
}
export default BoardVarias funcionalidades interesantes quedaron por explicar. Puedes ver su implementación en el repositorio del proyecto en GitHub, y también puedes jugar el juego aquí.

