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 FramerMotion. Es fundamental tener en cuenta que este proyecto se inició utilizando la herramienta Vite.
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 configuracion generados por Vite.
Para el desarrollo de la lógica y funcionalidad hay varias constantes importantes, estas son:
OPTIONS
Las opciones de juego X y Y.WINNIG_COMBO
Las combos ganadores del juego de juego. Para representar el tablero vamos a utilizar una matriz y cada número será la posicion de la opción X o Y. 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 const
Cada icon que aparece en cada casilla del tablero de juego, será un componente <SquareIcon />
. Este recibe como prop el turno turn
en el que va el juego, es decir, una OPTIONS
de las constantes anteriores. El componente renderiza un svg de FramerMotion dependiendo del turno para que esté animado.
import { m } from 'framer-motion'
import { type SquareOptions } from '../types/types'
import { OPTIONS } from '../consts/consts'
// Configuracion 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 opcion retornamos un Fragment
return (<></>)
}
export default SquareIcon
Ahora, cada casilla será un componente <Square />
. Este componente recibe un Children
que será una SquareOptions
(X
, Y
o null
), un index
que será la posición en el tablero de dicha casilla y una funcion 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, Y o null
index: number
onClick: (index: number) => void
}
const Square: React.FC<Props> = ({ children, index, onClick }): JSX.Element => {
return (
<div
className='sm:w-[120px] sm:h-[120px] w-[100px] h-[100px] flex items-center justify-center text-6xl font-semibold cursor-pointer md:hover:bg-slate-900 transition-colors'
onClick={() => onClick(index)}>
// Componente de framermotion para animar el componente dentro de el
<AnimatePresence>
<SquareIcon turn={children} />
</AnimatePresence>
</div>
)
}
export default Square
La funcionalidad general del juego se separó en 2 partes: la lógica y la parte del cliente donde se implementaros diversos hooks.
Para verificar quien es el ganador de la partida utilizamos la siguiente lógica. Cabe aclarar que board
es una matríz 3x3 que puede tener los valores X
, Y
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 funcionalidad que se creó 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é guardade 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 mostrar un popup a este.
const validateWinner = (newBoard: SquareOptions[]): void => {
if (verifyWinner(newBoard)) {
setWinner(turn)
setWinnerPopup(true)
setTimeout(() => {
setWinnerPopup(false)
resetBoard()
}, 3000)
}
}
return {
resetBoard,
board,
winner,
handleClick,
winnerPopup,
turn
}
}
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="relative grid grid-cols-3 grid-rows-3 mt-10 board">
{
board.map((value, index) => {
return (
<Square key={index} index={index} onClick={handleClick}>
{value}
</Square>
)
})
}
</main>
<Footer turn={turn} resetBoard={resetBoard} />
</>
)
}
export default Board
Varias funcionalidades interesantes quedaron por explicar, puedes ver su implementación en el repositorio del proyecto en GitHub, tambien puedes ver el juego dando click aquí.