DEV Community

Maximiliano Burgos
Maximiliano Burgos

Posted on

Hice un clon de Cookie Clicker en React

Antes de empezar a leer este artículo y por motivos de contexto, te recomiendo leer la historia que me llevó a terminar tomando las decisiones para crear este clon. Lo podés encontrar acá:

Aclaro que este artículo va a profundizar mucho en los aspectos técnicos que me llevaron a armar el proyecto, por lo que sería recomendable que tengas nociones básicas de desarrollo (web preferentemente) para entenderlo.

Como he mencionado anteriormente, pueden encontrar el repositorio con el código completo aquí.

Primeras decisiones de arquitectura

Para el que no lo sepa, Cookie Clicker es un juego desarrollado puramente en web, con HTML para dar la estructura, CSS para los estilos y finalmente Javascript para la parte lógica de programación. Seguramente tenga algunas librerías que desconozco, pero en escencia, esos son sus pilares principales.

Desarrollar un juego web otorga ciertas facilidades con respecto a la adaptabilidad, dado que la parte responsiva, por ejemplo, nos resuelve que éste sea compatible en múltiples resoluciones.

Por otro lado, la curva de aprendizaje de las tecnologías web (en general) es bastante baja: en un mes ya estás dominando los fundamentos de JS, por ejemplo. Por supuesto, hay conceptos más complejos como patrones reactivos, infraestructura o incluso maquetar una web y que todo funcione sin romperse en múltiples navegadores, sistemas operativos y versiones.

Pero a fin de cuentas, cualquier tecnología explorada en profundidad se vuelve compleja: hay años, incluso décadas de desarrollo detrás de cada una. Es normal que vos, Jose Perez, sentado en tu PC, no logres entender por donde empezar y qué terminar siendo ante la gran cantidad de información que ronda por ahí.

Por eso hay que sentarse y trabajar en el stack de tecnologías acorde a las necesidades que tengamos en base a un proyecto, o una serie de ellos. En este caso, consideré que seguir los pasos de Cookie Clicker era lo más acertado.

Por supuesto, estamos hablando de un videojuego que lleva más de una década en desarrollo: las formas de programar cambiaron mucho desde entonces, y la idea era quedarse con la cáscara, pero descartar la yema del huevo.

Tecnologías involucradas

Por esta razón, este juego y los siguientes van a apoyarse en desarrollos web, pero con algunas librerías adicionales.

Por un lado tenemos a React y Typescript, conjunto de ViteJS para inicializar el proyecto. En realidad esto último es una mentira a medias, porque el que creará todo será Tauri, el cual se apoya en Vite para concluir el armado, con configuraciones adicionales de Rust.

React sigue el paradigma reactivo, lo cual nos permite trabajar con componentes independientes que se pueden comunicar entre ellos y reaccionar mediante eventos. Este tipo de comportamientos esta reflejado en cualquier motor de desarrollo de juegos que utilicemos, tal como Unity, Unreal o Godot, por nombrar algunos.

Typescript es un "must", porque nos da una estructura sólida y evita el tipado dinámico. En un ecosistema donde van a existir muchísimos tipos de datos interactuando mediante componentes, es necesario establecer orden y evitar que un entero ocupe el lugar de un string, y viceversa.

Tauri es mi alternativa a ElectronJS. Dediqué unas dos semanas a Rust para entenderlo, aunque no es necesario que hagas lo mismo que yo. Podés compilar un ejecutable en utilizando esta librería simplemente creando el proyecto web que necesites; incluso migrando uno. Yo lo aprendí por cualquier eventualidad, o si en algun momento quería armar algun complemento para Tauri.

Estructura de componentes

En principio tenemos el típico main.tsx de toda la vida, envolviendo al componente App:

ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);
Enter fullscreen mode Exit fullscreen mode

En el mismo, también estoy incluyendo la librería de Bootstrap:

import 'bootstrap/dist/css/bootstrap.min.css'
import 'bootstrap'
Enter fullscreen mode Exit fullscreen mode

Dentro del componente App, tengo varias cosas:

function App() {
  const [cookieAmount, setCookieAmount] = useState(0);
  const [inventory, setInventory] = useState<InventoryType[]>([])

  return (
    <CookieContext.Provider value={{cookieAmount, setCookieAmount}}>
      <InventoryContext.Provider value={{inventory, setInventory}}>
        <div className='d-flex flex-column min-vh-100 justify-content-center align-items-center'>
          <div className="row">
            <div className="col">
              <Cookie />
            </div>
            <div className="col">
              <Shop />
            </div>
          </div>
        </div>
      </InventoryContext.Provider>
    </CookieContext.Provider>
  )
}
Enter fullscreen mode Exit fullscreen mode

Por un lado, utilizo un useState para trabajar con la cantidad de galletas en el juego. Estas requieren un manejo global, por lo tanto tengo un CookieContext encargado de llevarlo a cualquier componente que lo requiera:

const CookieContext = React.createContext<CookieProps>({
  cookieAmount: 0,
  setCookieAmount: () => {},
});
Enter fullscreen mode Exit fullscreen mode

También tengo un useState para el inventario, responsable de almacenar las mejoras compradas en el juego:

const [inventory, setInventory] = useState<InventoryType[]>([])
Enter fullscreen mode Exit fullscreen mode

Tal como pasa con las galletas, también tiene su contexto:

const InventoryContext = React.createContext<InventoryProps>({
  inventory: [],
  setInventory: () => {},
});
Enter fullscreen mode Exit fullscreen mode

La única diferencia es que maneja un tipo específico para manejar los items del inventario:

export type InventoryType = {
  shopItem: ItemType;
  amount: number;
};
Enter fullscreen mode Exit fullscreen mode

Luego, dentro del diseño en dos columnas, tenemos el componente de la galleta y la tienda de mejoras.

Componente Cookie

En el caso de la galleta en si, se maneja un estado para generar la animación de click que tiene el juego original:

const [isAnimating, setIsAnimating] = useState(false);

const incrementCookie = () => {
  if (!isAnimating) {
    setIsAnimating(true)

    setTimeout(() => {
      setIsAnimating(false)
    }, 100);
  }
};

<img onClick={incrementCookie} />
Enter fullscreen mode Exit fullscreen mode

Por otro lado, armamos un estado para manejar el valor de un click manual. Por defecto es 1, pero a medida que compramos mejoras de clicks, va incrementando:

const [clickerUpgrade, setClickerUpgrade] = useState(0)
Enter fullscreen mode Exit fullscreen mode

Estas mejoras las traemos en el momento en que se genera el componente así como también en cada cambio del inventario:

useEffect(() => {
  setClickerUpgrade(getTotalGiveInventory(ItemMethodEnum.M))
}, [inventory])
Enter fullscreen mode Exit fullscreen mode

Puede que la función getTotalGiveInventory te resulte desconocida, y es porque armé un hook personalizado para manejar el inventario con métodos específicos:

const { inventory, getTotalGiveInventory } = useInventory();
Enter fullscreen mode Exit fullscreen mode

En este caso particular, obtenemos el total de clicks mejorados por el tipo de mejora:

const getTotalGiveInventory = (method: ItemMethodEnum) => {
  const inventoryByMethod = inventory.filter((inv) => inv.shopItem.method === method)

  return inventoryByMethod.reduce((accumulator, inv) => {
    return accumulator + (inv.amount * inv.shopItem.give)
  }, 0)
}
Enter fullscreen mode Exit fullscreen mode

Para entender esto, necesito explicarles los dos tipos que manejo:

  • Mejoras manuales (ItemMethodEnum.M): son aquellas que se otorgan cuando hacemos click en la galleta.
  • Mejoras automáticas (ItemMethodEnum.A): corren cada segundo, se les suele llamar "autoclicks".

Por otro lado, para comprender los cálculos involucrados tanto en filter como reduce, les muestro dos mejoras que existen en la tienda:

const items: ItemType[] = [
    {
        name: "Click",
        icon: HiCursorClick,
        cost: 1,
        give: 1,
        method: ItemMethodEnum.M
    },
    {
        name: "Cursor",
        icon: BsHandIndexThumb,
        cost: 1,
        give: 0.01,
        method: ItemMethodEnum.A
    },
    (...)
]
Enter fullscreen mode Exit fullscreen mode

El atributo give es el valor que se sumariza en nuestra función según el tipo de item (manual o automático). A continuación, les muestro las estructura de ItemType para ilustrar mejor este ejemplo:

export type ItemType = {
    name: string,
    icon: IconType,
    cost: number,
    give: number,
    method: ItemMethodEnum,
}
Enter fullscreen mode Exit fullscreen mode

Este comportamiento se refleja en el momento de llamar a la función incrementCookie del componente Cookie, cuando actualizamos el valor de CpS (cookies por click):

setCookieAmount(cookieAmount + 1 + clickerUpgrade)
Enter fullscreen mode Exit fullscreen mode

En el caso del manejo de CpS automáticos, tenemos al componente CookieCounter, el cual tiene un comportamiento similar a Cookie:

useEffect(() => {
  const intervalId = setInterval(() => {
    setCookieAmount(cookieAmount + giveInventory);
  }, 100);

  return () => clearInterval(intervalId);
}, [cookieAmount]);
Enter fullscreen mode Exit fullscreen mode

Como cookieAmount cambia constantemente, esto entra en un bucle infinito donde siempre estan aumentando las galletas. El que define cuántos deben ser los CpS automáticos es otro useEffect:

useEffect(() => {
  setGiveInventory(getTotalGiveInventory(ItemMethodEnum.A))
}, [inventory])
Enter fullscreen mode Exit fullscreen mode

Y finalmente en la parte visual, hacemos un redondeo porque a veces las compras o ventas pueden generar decimales:

return (
  <div>
    <h1>
      {Math.floor(cookieAmount)} galletas
    </h1>
    <h2>{giveInventory.toFixed(2)} g/s</h2>
  </div>
);
Enter fullscreen mode Exit fullscreen mode

Componente Shop

Este componente es muy sencillo porque contiene la lista de mejoras, pero en otro componente distinto, con el fin de separar las responsabilidades de cada uno.

const Shop: React.FC = () => {
  return (
    <div className="text-center">
      <h1>Tienda</h1>
      <div className="row">
        <ShopItemList />
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Con ShopItemList ocurre algo similar:

const ShopItemList: React.FC = () => {
  const itemList = items.map((item, index) => (
    <ShopItem key={index} shopItem={item} />
  ));

  return <>{itemList}</>;
}
Enter fullscreen mode Exit fullscreen mode

Recorremos una lista de items que se carga desde un archivo que se encuentra en data/shop_items, el cual se pudo observar cuando estudiábamos el manejo de mejoras manuales y automáticas:

import items from "../../data/shop_items";
Enter fullscreen mode Exit fullscreen mode

Por cada item de la lista, renderizamos un componente ShopItem que se lleva por parámetro el item en sí. En este componente, hay varios aspectos observables:

const itemSellingCost = shopItem.cost / 2;
Enter fullscreen mode Exit fullscreen mode

La constante itemSellingCost nos permite vender el item a la mitad del costo de compra. Es una forma sencilla que encontré para definir valores de venta.

const { inventory, getItemInventory, setAmountItemInventory } = useInventory();
Enter fullscreen mode Exit fullscreen mode

Volvemos a hacer uso del hook que definimos para inventory, pero esta vez nos llevamos getItemInventory para obetener un item del inventario, y setAmountItemInventory para modificar la cantidad del item existente. Para entender esto en mayor detale, podemos ver la estructura de un item en el inventario:

export type InventoryType = {
  shopItem: ItemType;
  amount: number;
};
Enter fullscreen mode Exit fullscreen mode

Podemos observar que shopItem es de tipo ItemType, lo cual nos indica que podemos tener un item que sale de shop_items. Por otro lado, tenemos un campo numérico amount, el cual va a manejar la cantidad de "shopItem" que poseemos. Gracias Typescript por tanto, y perdón por tan poco.

Siguiendo con la exploración de nuestro componente ShopItem, tenemos un useEffect que va a obtener el item del inventario y luego setearlo en un useState para que se actualice en tiempo real:

useEffect(() => {
  const itemInventory = getItemInventory(shopItem);
  setItemAmount(itemInventory.amount);
}, [inventory]);
Enter fullscreen mode Exit fullscreen mode

Luego, en el maquetado del item, tenemos dos botones (para compra y venta) y cada uno va a la misma función transaction, la cual maneja un action "B" (buy, compra) y "S" (sell, venta):

<div className="col">
  <button
    className="btn btn-success"
    disabled={cookieAmount < shopItem.cost}
    onClick={transaction("B")}
  >
    <h5>${shopItem.cost}</h5>
  </button>

  <button className="ms-3 btn btn-danger" onClick={transaction("S")}>
    <h5>${itemSellingCost}</h5>
  </button>
</div>
Enter fullscreen mode Exit fullscreen mode

Dentro de la función transaction, validamos dos casos:

  • En el caso de la compra, restamos la cantidad de galletas al costo del item (la validación de la cantidad que podemos comprar está determinada por el disabled del mismo botón) y luego llamamos al método setAmountItemInventory, que se encargará de manejar el agregado o modificación del item en el inventario.
  • En el caso de la venta, sumamos a la cantidad de galletas el costo del item dividido dos, como vimos al principio.
const transaction = (action: "B" | "S") => () => {
  if (action == "B") {
    setCookieAmount(cookieAmount - shopItem.cost);
    setAmountItemInventory(shopItem, "B");
  } else {
    setCookieAmount(cookieAmount + itemSellingCost);
    setAmountItemInventory(shopItem, "S");
  }
};
Enter fullscreen mode Exit fullscreen mode

Con esto tenemos todo el manejo del juego resuelto, dado que cookieAmount es parte de CookieContext, así que modifica las galletas globalmente. Y en el caso del inventory, nuestro hook se encarga de modificar mediante determinados métodos la cantidad de elementos en el inventario.

Compilación

Para compilar este juego en un ejecutable, gracias a Tauri, no nos lleva más que un simple comando en consola:

npm run tauri dev

Y lograremos obtener una ventana como esta:

Proyecto Tauri

Conclusiones

Este juego me desafió en muchos aspectos, desde lo técnico hasta el diseño y maquetado de cada aspecto visual. Me permitió fortalecerme en tecnologías que ya conocía, así como también aprender nuevas que no esperaba introducir a mi stack este año, como Rust.

Recomiendo plantearse proyectos como éste cuando quieran dominar tecnologías nuevas, porque no solo es entretenido, sino también extremadamente complicado (se tenía que decir, y se dijo). Esa dificultad es la que realmente va a marcar la diferencia entre seguir un tutorial, y hacer algo realmente desde cero.

Y, como consejo final: terminar el proyecto, no dejarlo a medias, es la parte más importante de este proceso.

¡Nos vemos en los próximos artículos!

Top comments (0)