Una aplicación web interactiva de tarot desarrollada con JavaScript y Three.js que permite a los usuarios descubrir su carta del día mediante una experiencia 3D.
El objetivo del proyeto consistía en crear una página de tarot donde el usuario pueda hacer click en una carta boca abajo, la cual se voltea revelando una carta aleatoria con un mensaje inspirador para comenzar el día.
La aplicación utiliza renderizado 3D para crear una experiencia visualmente atractiva.
- Instalación y Ejecución
- Estructura del Proyecto
- Decisiones de Diseño
- Arquitectura Técnica
- Funcionamiento de Three.js
- Sistema de Texturas
- Animaciones y Rotaciones
- Flujo de Interacción
- Mejoras Futuras
- Node.js (versión 14 o superior)
- npm (incluido con Node.js)
-
Clonar o descargar el repositorio
-
Instalar dependencias en \Tarot-App\tarot-app:
npm install- Ejecutar el servidor de desarrollo (desde \Tarot-App\tarot-app):
npm run dev- Abrir el navegador en
http://localhost:5173
El proyecto utiliza dos dependencias principales:
- Three.js (^0.160.0): Biblioteca de renderizado 3D que facilita el trabajo con WebGL
- Vite (^5.0.0): Herramienta de desarrollo que proporciona un servidor rápido y compilación eficiente
La elección de estas herramientas específicas se explica más adelante en la sección de decisiones de diseño.
tarot-app/
├── assets/
│ ├── t1.jpg
│ ├── t2.jpg
│ └── ... (t3.jpg hasta t12.jpg)
├── index.html
├── style.css
├── main.js
├── cards.js
└── package.json
La estructura fue diseñada para ser simple, (fácil de navegar y comprender rápidamente).
index.html: Punto de entrada de la aplicación. Contiene la estructura HTML mínima necesaria: un contenedor para el canvas de Three.js, un panel de información de la carta que se muestra tras voltearla, y un footer con instrucciones para el usuario.
style.css: Todos los estilos visuales están concentrados en un único archivo. Incluye el gradiente de fondo, animaciones CSS para los textos, efectos para el panel de información, y settings para responsividad.
main.js: Archivo principal que contiene toda la lógica de Three.js, manejo de eventos, animaciones de volteo y renderizado. Es el corazón de la aplicación.
cards.js: Base de datos de las cartas del tarot. Separar los datos de la lógica permite modificar fácilmente el contenido sin tocar el código de renderizado.
assets/: Carpeta que contiene las imágenes reales de las cartas del tarot (t1.jpg hasta t12.jpg).
Aunque el desafío podría haberse resuelto con archivos HTML/JS planos, elegí Vite por varias razones prácticas:
-
Hot Module Replacement: Durante el desarrollo, cualquier cambio se refleja instantáneamente en el navegador sin necesidad de refrescar manualmente. Esto acelera significativamente el proceso de prueba y ajuste.
-
Servidor de desarrollo integrado: Evita problemas de CORS al cargar imágenes y módulos, que son comunes cuando se trabaja con archivos locales directamente.
-
Build optimizado: Aunque no es crítico para una demo, Vite puede generar una versión optimizada para producción con un solo comando.
Esta biblioteca es ideal para el proyecto porque:
-
Abstracción de WebGL: Three.js simplifica enormemente el trabajo con WebGL, que de otra forma requeriría cientos de líneas de código de bajo nivel para lograr el mismo resultado.
-
Geometrías predefinidas: La clase BoxGeometry permite crear la carta 3D de forma trivial, con control total sobre sus dimensiones y materiales.
-
Sistema de texturas: Three.js maneja automáticamente la carga y aplicación de texturas a superficies 3D, incluyendo texturas generadas en canvas.
-
Renderizado eficiente: El loop de animación de Three.js está optimizado para 60fps.
En lugar de usar una imagen estática para el dorso de la carta, decidí generarlo programáticamente usando Canvas API. Esta decisión tiene varias ventajas:
-
Cero dependencias de assets externos: El dorso siempre funciona, sin importar si las imágenes de las cartas se cargan o no.
-
Customización total: Es fácil modificar colores, símbolos o diseño sin necesidad de editar imágenes en un programa externo.
El diseño incluye gradientes radiales, bordes dorados, símbolos místicos (luna, sol, estrellas) y texto con sombras, todo generado con código JavaScript.
El archivo main.js mantiene el estado de la aplicación en variables globales:
let scene, camera, renderer, cardGroup;
let isFlipped = false;
let isAnimating = false;
let currentCard = null;
let autoRotate = true;Se tomó este camino de diseño ya que:
- Es fácil de entender y debuggear
- No requiere bibliotecas adicionales
- El estado es simple y no se comparte entre componentes
isFlipped y isAnimating son flags booleanos que previenen interacciones mientras hay animaciones en curso. Sin estos, el usuario podría hacer click múltiples veces y causar comportamientos inesperados.
autoRotate controla si la carta gira automáticamente. Se desactiva al hacer click y se reactiva al resetear (solicitar nueva carta), lo que mejora la experiencia ya que la carta no se mueve mientras el usuario lee su mensaje.
La función init() configura toda la escena 3D:
function init() {
scene = new THREE.Scene();
camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000);
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
// ... configuración de luces y eventos
}Scene: Es el contenedor donde viven todos los objetos 3D. Solo contiene la carta y las luces.
PerspectiveCamera: Simula la vista y ajusta lo observado en pantalla de la escena. El parámetro 75 es el campo de visión (field of view) en grados.
WebGLRenderer: El motor que realmente dibuja los píxeles en pantalla. La opción alpha: true hace el fondo transparente, permitiendo que se vea el gradiente CSS detrás.
Se usan tres tipos de luz:
const ambientLight = new THREE.AmbientLight(0xffffff, 1.2);
const pointLight1 = new THREE.PointLight(0xffffff, 0.4);
const pointLight2 = new THREE.PointLight(0xffffff, 0.4);AmbientLight: Ilumina todos los objetos por igual, sin dirección. Esto asegura que no haya partes completamente negras.
PointLight: Luces que emiten desde un punto específico. Las dos luces puntuales desde ángulos diferentes (5,5,5 y -5,-5,5) crean profundidad y hacen que los bordes de la carta sean visibles.
const geometry = new THREE.BoxGeometry(2.5, 3.5, 0.05);BoxGeometry crea un cubo (o en este caso, un rectángulo muy delgado). Los parámetros son:
- 2.5 unidades de ancho
- 3.5 unidades de alto (proporción similar a cartas reales)
- 0.05 unidades de profundidad (grosor mínimo para que parezca una carta física)
Un BoxGeometry tiene 6 caras (frente, atrás, arriba, abajo, izquierda, derecha). Podemos asignar un material diferente a cada una:
const materials = [
new THREE.MeshPhongMaterial({ color: 0x1a1a2e }), // derecha
new THREE.MeshPhongMaterial({ color: 0x1a1a2e }), // izquierda
new THREE.MeshPhongMaterial({ color: 0x1a1a2e }), // arriba
new THREE.MeshPhongMaterial({ color: 0x1a1a2e }), // abajo
new THREE.MeshPhongMaterial({ map: backTexture }), // frente (índice 4)
new THREE.MeshPhongMaterial({ map: backTexture }) // atrás (índice 5)
];Las caras laterales (0-3) son oscuras porque nunca se ven directamente, solo dan grosor a la carta.
Las caras principales (4 y 5) reciben texturas. Inicialmente ambas muestran el dorso decorativo. Cuando se voltea, la cara 5 se reemplaza con la imagen de la carta seleccionada.
MeshPhongMaterial se eligió porque responde a la luz de forma realista, creando highlights y sombras sutiles que dan sensación de objeto físico.
cardGroup = new THREE.Group();
cardGroup.add(card);En lugar de rotar el mesh directamente, lo envolvemos en un Group. Esto tiene ventajas:
-
Si en el futuro queremos agregar efectos adicionales (partículas, glow), podemos añadirlos al grupo sin afectar la rotación de la carta.
-
Permite manejar la posición, rotación y escala de forma independiente.
-
Hace el código más claro:
cardGroup.rotation.yes explícito en su propósito.
La textura se genera en un canvas HTML invisible:
const canvas = document.createElement('canvas');
canvas.width = 512;
canvas.height = 712;
const ctx = canvas.getContext('2d');Resolución 512x712: Es potencia de 2 en la dimensión horizontal (512 = 2^9), lo cual es importante para optimización en WebGL.
El proceso de dibujo sigue este orden:
- Fondo con gradiente del centro hacia afuera
- Círculos concéntricos sutiles para textura
- Bordes dorados (exterior, medio, interior)
- Luna creciente y estrellas en la parte superior
- Texto "TAROT Matutino" en el centro
- Símbolos 'místicos' en las esquinas
- Sol con rayos en la parte inferior
Cada elemento se dibuja con fillStyle (relleno) o strokeStyle (contorno), capa por capa.
Finalmente, new THREE.CanvasTexture(canvas) convierte el canvas en una textura que Three.js puede aplicar a una superficie 3D.
const img = new Image();
img.crossOrigin = "anonymous";
img.src = cardData.image;crossOrigin: Necesario para evitar problemas de seguridad CORS al manipular la imagen en canvas.
La carga es asíncrona y usa callbacks:
img.onload = () => {
// Dibujar imagen en canvas
// Crear textura
// Aplicar a material
};
img.onerror = () => {
console.error('Error al cargar imagen');
};Este patrón asegura que la aplicación no se bloquee esperando que carguen las imágenes. Si una imagen falla, simplemente se registra el error en consola pero la aplicación sigue funcionando.
if (progress > 0.5 && !imageLoaded) {
imageLoaded = true;
loadCardImage(currentCard);
}La imagen se carga a mitad del volteo por dos razones:
-
Buen timing: Para cuando termina el volteo (100% de progreso de volteo), la imagen ya está cargada y aplicada, creando una transición suave (se esconde transición en velocidad de giro).
-
Ocultamiento del delay: Si la imagen tarda en cargar, el usuario no lo nota porque está viendo la animación de volteo. Es mejor que cargar antes y hacer que el usuario espere sin feedback visual.
function animate() {
requestAnimationFrame(animate);
if (!isAnimating && !isFlipped && autoRotate) {
cardGroup.rotation.y += 0.003;
cardGroup.position.y = Math.sin(Date.now() * 0.001) * 0.1;
}
renderer.render(scene, camera);
}requestAnimationFrame es el método estándar para animaciones suaves en navegadores. Sincroniza con la tasa de refresco del monitor (usualmente 60fps).
Rotación automática: rotation.y += 0.003 rota la carta 0.003 radianes por frame. A 60fps, esto resulta en aproximadamente una rotación completa cada 35 segundos.
Movimiento flotante: Math.sin(Date.now() * 0.001) crea un movimiento ondulante suave. Date.now() proporciona milisegundos, dividido entre 1000 lo convierte en segundos, y sin() produce valores entre -1 y 1, multiplicados por 0.1 para amplitud sutil.
El volteo es más complejo porque debe:
- Completar al menos una vuelta completa (para que se vea fluida)
- Terminar exactamente en PI radianes (mirando al frente)
- Aplicar easing para aceleración/desaceleración natural
const normalizedStart = startRotation % (Math.PI * 2);
const remainingToComplete = (Math.PI * 2) - normalizedStart;
const totalRotation = remainingToComplete + Math.PI * 2 + Math.PI;normalizedStart: Si la carta ha dado múltiples vueltas, esto la normaliza a su posición en el primer ciclo (0 a 2PI).
remainingToComplete: Cuánto falta para completar la vuelta actual.
totalRotation: Lo que falta + una vuelta completa + medio giro (PI) para voltear.
Por ejemplo: Si la carta está en 1.5 radianes:
- remainingToComplete = 2PI - 1.5 ≈ 4.78
- totalRotation = 4.78 + 2PI + PI ≈ 14.2 radianes
Esto asegura una animación fluida sin importar cuándo el usuario haga click.
let eased;
if (progress < 0.5) {
// Primera mitad: ease-in (acelera suavemente)
eased = 4 * progress * progress * progress;
} else {
// Segunda mitad: ease-out (desacelera suavemente)
eased = 1 - Math.pow(-2 * progress + 2, 3) / 2;
}Esta es una función ease-in-out cúbica. Significa:
- Primera mitad (ease-in): Acelera gradualmente (4x³)
- Segunda mitad (ease-out): Desacelera gradualmente
Sin easing, la animación sería lineal y se vería robótica. Con easing, se ve más natural, como si la carta tuviera peso físico.
const scale = 1 + Math.sin(progress * Math.PI) * 0.15;
cardGroup.scale.set(scale, scale, 1);Math.sin(progress * Math.PI) va de 0 a 1 y de vuelta a 0 durante la animación. Esto hace que la carta "crezca" ligeramente al voltearse y vuelva a su tamaño normal, añadiendo énfasis visual al momento del volteo.
El factor 0.15 significa que crece hasta un 15% más grande en el punto medio.
if (progress < 1) {
requestAnimationFrame(animateFlip);
} else {
cardGroup.rotation.y = Math.PI;
cardGroup.scale.set(1, 1, 1);
cardGroup.position.y = 0;
isAnimating = false;
isFlipped = true;
showCardInfo();
}Cuando la animación termina, forzamos valores exactos en lugar de confiar en la última iteración. Esto previene acumulación de errores de punto flotante que podrían hacer que la carta quede ligeramente desalineada.
- Se ejecuta
init()que configura Three.js - Se llama
createCard()que genera la geometría y texturas - Comienza el loop de animación con rotación automática
- El footer muestra "Haz click en la carta"
renderer.domElement.addEventListener('click', handleCardClick);El evento está en el canvas, no en el documento completo, para evitar que clicks fuera de la carta activen la animación.
if (isAnimating || isFlipped) return;Esta guardia previene múltiples clicks durante la animación o después de haber volteado la carta.
currentCard = getRandomCard();La función en cards.js simplemente usa Math.random() para seleccionar un índice aleatorio del array de cartas. Esto asegura distribución uniforme.
Se inicia flipCard() que:
- Calcula la rotación total necesaria
- Inicia un loop de animación con
requestAnimationFrame - A mitad de camino, carga la imagen de la carta
- Al terminar, fija la posición en exactamente PI radianes
setTimeout(() => {
cardInfo.classList.remove('hidden');
footer.style.display = 'none';
}, 300);Un delay de 300ms da tiempo para que el usuario aprecie la carta volteada antes de que aparezca el panel de texto. Sin este delay, todo aparecería simultáneamente y sería visualmente abrumador.
document.getElementById('reset-btn').addEventListener('click', resetCard);El botón "Nueva Carta":
- Oculta el panel de información
- Restaura la textura del dorso en ambas caras
- Anima la carta volteando de vuelta
- Reactiva la rotación automática
El reset restaura completamente el estado inicial, permitiendo seleccionar infinitas cartas sin necesidad de recargar la página.
El archivo cards.js contiene un array con 12 cartas:
export const tarotCards = [
{
id: 1,
name: "El Sol",
emoji: "☀️",
message: "Hoy es un día lleno de energía...",
color: "#FFD700",
image: "/assets/t1.jpg"
},
// ... más cartas
];Esta estructura fue elegida porque:
- Simplicidad: No requiere base de datos externa ni API calls
- Modificabilidad: Agregar o editar cartas es trivial
- Type safety implícito: Cada carta tiene la misma estructura, fácil de validar mentalmente
El campo color se usó inicialmente para el fondo de cartas generadas antes de tener los assets reales. Se mantiene como fallback si las imágenes no cargan.
El campo emoji se usa en el título del panel de información para reforzar visualmente cada carta.
El CSS incluye media queries para adaptar la interfaz:
@media (max-width: 768px) {
#canvas-container { height: 400px; }
header h1 { font-size: 2.2rem; }
}También hay un listener de resize en JavaScript:
window.addEventListener('resize', onWindowResize);
function onWindowResize() {
camera.aspect = width / height;
camera.updateProjectionMatrix();
renderer.setSize(width, height);
}updateProjectionMatrix() es crucial: sin esto, al cambiar el tamaño de la ventana, la cámara mantendría el aspect ratio anterior y la escena se vería distorsionada.
Envolver la carta en un Group permite futuras expansiones sin refactorizar el código de animación.
Los flags isAnimating e isFlipped evitan comportamientos inesperados y potenciales bugs difíciles de reproducir.
El dorso generado con canvas carga instantáneamente y no depende de requests de red.
Las imágenes solo se cargan cuando se seleccionan, no todas al inicio. Esto reduce el tiempo de carga inicial y el uso de memoria.
Un solo requestAnimationFrame loop maneja tanto la rotación automática como el renderizado, en lugar de múltiples timers que podrían causar problemas de sincronización.
WebGL puede ser pesado en hardware antiguo. En esos casos, la animación podría no ser fluida (menos de 60fps).
Math.random() no es criptográficamente seguro, pero para seleccionar cartas de tarot es suficiente.
Las imágenes de las cartas no están optimizadas automáticamente. En un proyecto de producción, se usaría compresión y formato moderno (WebP).
Este proyecto demuestra cómo crear una experiencia web 3D interactiva sin necesidad de frameworks complejos. La combinación de Three.js para renderizado, Canvas API para texturas procedurales, y JavaScript vanilla para lógica resulta en una aplicación ligera pero visualmente atractiva.
El código incluye conceptos avanzados como animaciones con easing, manejo asíncrono de assets, y renderizado 3D en tiempo real.
De este modo, el resultado es una aplicación funcional, atractiva, y extensible.
Desarrollado por Pablo Reyes Pomés
Estudiante de Ingeniería Civil en Computación, Universidad de Chile