Saltar al contenido
Blog
02
6 min de lectura

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.

  • react
  • typescript
  • tailwind

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 const

Componentes 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 SquareIcon

Ahora, 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 Square

La 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 Board

Varias 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í.

Volver al blog