Hice un clon de Cookie Clicker en React
Maximiliano Burgos
Posted on May 29, 2023
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á:
Creo que me convertí en un desarrollador en React y Typescript
Maximiliano Burgos ・ May 16 ・ 5 min read
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>
);
En el mismo, también estoy incluyendo la librería de Bootstrap:
import 'bootstrap/dist/css/bootstrap.min.css'
import 'bootstrap'
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>
)
}
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: () => {},
});
También tengo un useState para el inventario, responsable de almacenar las mejoras compradas en el juego:
const [inventory, setInventory] = useState<InventoryType[]>([])
Tal como pasa con las galletas, también tiene su contexto:
const InventoryContext = React.createContext<InventoryProps>({
inventory: [],
setInventory: () => {},
});
La única diferencia es que maneja un tipo específico para manejar los items del inventario:
export type InventoryType = {
shopItem: ItemType;
amount: number;
};
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} />
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)
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])
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();
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)
}
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
},
(...)
]
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,
}
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)
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]);
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])
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>
);
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>
);
}
Con ShopItemList ocurre algo similar:
const ShopItemList: React.FC = () => {
const itemList = items.map((item, index) => (
<ShopItem key={index} shopItem={item} />
));
return <>{itemList}</>;
}
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";
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;
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();
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;
};
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]);
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>
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");
}
};
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:
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!
Posted on May 29, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.