En el desarrollo web moderno, los límites entre las aplicaciones web clásicas y las aplicaciones web se difuminan cada día. Hoy en día podemos crear no sólo sitios web interactivos, sino también juegos completos directamente en el navegador. Una de las herramientas que hace esto posible es la biblioteca React Three Fiber , una poderosa herramienta para crear gráficos 3D basados en Three.js utilizando la tecnología React .
React Three Fiber es un contenedor de Three.js que utiliza la estructura y los principios de React para crear gráficos 3D en la web. Esta pila permite a los desarrolladores combinar el poder de Three.js con la conveniencia y flexibilidad de React , haciendo que el proceso de creación de una aplicación sea más intuitivo y organizado.
En el corazón de React Three Fiber está la idea de que todo lo que creas en una escena es un componente de React . Esto permite a los desarrolladores aplicar patrones y metodologías familiares.
Una de las principales ventajas de React Three Fiber es su facilidad de integración con el ecosistema React . Cualquier otra herramienta de React aún se puede integrar fácilmente al usar esta biblioteca.
Web-GameDev ha experimentado cambios importantes en los últimos años, evolucionando desde simples juegos Flash 2D hasta complejos proyectos 3D comparables a aplicaciones de escritorio. Este crecimiento en popularidad y capacidades hace de Web-GameDev un área que no se puede ignorar.
Una de las principales ventajas de los juegos web es su accesibilidad. Los jugadores no necesitan descargar e instalar ningún software adicional; simplemente hagan clic en el enlace en su navegador. Esto simplifica la distribución y promoción de juegos, poniéndolos a disposición de una amplia audiencia en todo el mundo.
Finalmente, el desarrollo de juegos web puede ser una excelente manera para que los desarrolladores prueben el desarrollo de juegos utilizando tecnologías familiares. Gracias a las herramientas y bibliotecas disponibles, incluso sin experiencia en gráficos 3D, es posible crear proyectos interesantes y de alta calidad.
Los navegadores modernos han recorrido un largo camino, evolucionando desde herramientas de navegación web bastante simples hasta plataformas potentes para ejecutar aplicaciones y juegos complejos. Los principales navegadores como Chrome , Firefox , Edge y otros se optimizan y desarrollan constantemente para garantizar un alto rendimiento, lo que los convierte en una plataforma ideal para desarrollar aplicaciones complejas.
Una de las herramientas clave que ha impulsado el desarrollo de los juegos basados en navegador es WebGL . Este estándar permitió a los desarrolladores utilizar la aceleración de gráficos por hardware, lo que mejoró significativamente el rendimiento de los juegos 3D. Junto con otras webAPI, WebGL abre nuevas posibilidades para crear impresionantes aplicaciones web directamente en el navegador.
Sin embargo, a la hora de desarrollar juegos para navegador, es fundamental tener en cuenta varios aspectos del rendimiento: la optimización de recursos, la gestión de la memoria y la adaptación a diferentes dispositivos son puntos clave que pueden afectar al éxito de un proyecto.
Sin embargo, las palabras y la teoría son una cosa, pero la experiencia práctica es otra muy distinta. Para comprender y apreciar realmente todo el potencial del desarrollo de juegos web, la mejor manera es sumergirse en el proceso de desarrollo. Por lo tanto, como ejemplo de desarrollo exitoso de un juego web, crearemos nuestro propio juego. Este proceso nos permitirá aprender aspectos clave del desarrollo, afrontar problemas reales y encontrarles soluciones, y ver lo potente y flexible que puede ser una plataforma de desarrollo de juegos web.
En una serie de artículos, veremos cómo crear un juego de disparos en primera persona utilizando las funciones de esta biblioteca y nos sumergiremos en el apasionante mundo de los desarrolladores de juegos web.
Repositorio en GitHub
¡Ahora comencemos!
En primer lugar, necesitaremos una plantilla de proyecto de React . Así que comencemos instalándolo.
npm create vite@latest
Instale paquetes npm adicionales.
npm install three @react-three/fiber @react-three/drei @react three/rapier zustand @tweenjs/tween.js
Luego elimine todo lo innecesario de nuestro proyecto.
En el archivo main.jsx , agregue un elemento div que se mostrará en la página como alcance. Inserte un componente Canvas y configure el campo de visión de la cámara. Dentro del componente Canvas , coloque el componente App .
Agreguemos estilos a index.css para estirar los elementos de la interfaz de usuario a la altura completa de la pantalla y mostrar el alcance como un círculo en el centro de la pantalla.
En el componente Aplicación agregamos un componente Cielo , que se mostrará como fondo en nuestra escena de juego en forma de cielo.
Creemos un componente Ground y colóquelo en el componente App .
En Tierra , cree un elemento de superficie plana. En el eje Y muévalo hacia abajo para que este plano esté en el campo de visión de la cámara. Y también voltea el plano en el eje X para hacerlo horizontal.
Aunque especificamos el gris como color del material, el avión parece completamente negro.
De forma predeterminada, no hay iluminación en la escena, así que agreguemos una fuente de luz ambientLight que ilumina el objeto desde todos los lados y no tiene un haz dirigido. Como parámetro establece la intensidad del brillo.
Para que la superficie del suelo no luzca homogénea, añadiremos textura. Haga un patrón de la superficie del piso en forma de celdas que se repiten a lo largo de toda la superficie.
En la carpeta de activos agregue una imagen PNG con una textura.
Para cargar una textura en la escena, usemos el gancho useTexture del paquete @react-tres/drei . Y como parámetro para el gancho pasaremos la imagen de textura importada al archivo. Establece la repetición de la imagen en los ejes horizontales.
Usando el componente PointerLockControls del paquete @react-tres/drei , fije el cursor en la pantalla para que no se mueva cuando mueva el mouse, sino que cambie la posición de la cámara en la escena.
Hagamos una pequeña edición para el componente Tierra .
Para mayor claridad, agreguemos un cubo simple a la escena.
<mesh position={[0, 3, -5]}> <boxGeometry /> </mesh>
Ahora mismo simplemente está colgado en el espacio.
Utilice el componente Física del paquete @react-tres/rapier para agregar "física" a la escena. Como parámetro configuramos el campo de gravedad, donde configuramos las fuerzas gravitacionales a lo largo de los ejes.
<Physics gravity={[0, -20, 0]}> <Ground /> <mesh position={[0, 3, -5]}> <boxGeometry /> </mesh> </Physics>
Sin embargo, nuestro cubo está dentro del componente de física, pero no le pasa nada. Para que el cubo se comporte como un objeto físico real, debemos envolverlo en el componente RigidBody del paquete @react-tres/rapier .
Después de eso, veremos inmediatamente que cada vez que la página se recarga, el cubo cae bajo la influencia de la gravedad.
Pero ahora hay otra tarea: es necesario hacer del suelo un objeto con el que el cubo pueda interactuar y más allá del cual no caiga.
Volvamos al componente Tierra y agreguemos un componente RigidBody como envoltura sobre la superficie del piso.
Ahora, al caer, el cubo permanece en el suelo como un objeto físico real.
Creemos un componente de jugador que controlará al personaje en la escena.
El personaje es el mismo objeto físico que el cubo agregado, por lo que debe interactuar con la superficie del piso así como con el cubo en la escena. Por eso agregamos el componente RigidBody . Y hagamos el personaje en forma de cápsula.
Coloque el componente Reproductor dentro del componente Física.
Ahora nuestro personaje ha aparecido en escena.
El personaje será controlado usando las teclas WASD y saltará usando la barra espaciadora .
Con nuestro propio gancho de reacción, implementamos la lógica de mover al personaje.
Creemos un archivo hooks.js y agreguemos una nueva función usePersonControls allí.
Definamos un objeto en el formato {"código clave": "acción a realizar"}. A continuación, agregue controladores de eventos para presionar y soltar teclas del teclado. Cuando se activen los controladores, determinaremos las acciones actuales que se están realizando y actualizaremos su estado activo. Como resultado final, el gancho devolverá un objeto con el formato {"acción en curso": "estado"}.
Después de implementar el gancho usePersonControls , debe usarse al controlar el personaje. En el componente Reproductor agregaremos seguimiento del estado de movimiento y actualizaremos el vector de la dirección del movimiento del personaje.
También definiremos variables que almacenarán los estados de las direcciones de movimiento.
Para actualizar la posición del personaje, usemos el marco proporcionado por el paquete @react-tres/fiber . Este gancho funciona de manera similar a requestAnimationFrame y ejecuta el cuerpo de la función aproximadamente 60 veces por segundo.
Explicación del código:
1. const playerRef = useRef(); Cree un enlace para el objeto del jugador. Este enlace permitirá la interacción directa con el objeto jugador en la escena.
2. const {adelante, atrás, izquierda, derecha, saltar} = usePersonControls(); Cuando se utiliza un gancho, se devuelve un objeto con valores booleanos que indican qué botones de control están presionados actualmente por el jugador.
3. useFrame((estado) => {... }); El gancho se llama en cada cuadro de la animación. Dentro de este gancho, se actualizan la posición y la velocidad lineal del jugador.
4. si (!playerRef.current) regresa; Comprueba la presencia de un objeto de jugador. Si no hay ningún objeto de jugador, la función detendrá la ejecución para evitar errores.
5. velocidad constante = playerRef.current.linvel(); Obtenga la velocidad lineal actual del jugador.
6. frontVector.set(0, 0, atrás - adelante); Establezca el vector de movimiento hacia adelante/atrás según los botones presionados.
7. sideVector.set(izquierda - derecha, 0, 0); Establezca el vector de movimiento izquierda/derecha.
8. dirección.subVectors(frontVector, sideVector).normalize().multiplyScalar(MOVE_SPEED); Calcule el vector final de movimiento del jugador restando los vectores de movimiento, normalizando el resultado (de modo que la longitud del vector sea 1) y multiplicando por la constante de velocidad de movimiento.
9. playerRef.current.wakeUp(); "Despierta" el objeto del jugador para asegurarse de que reacciona a los cambios. Si no utiliza este método, después de un tiempo el objeto "dormirá" y no reaccionará a los cambios de posición.
10. playerRef.current.setLinvel({ x: dirección.x, y: velocidad.y, z: dirección.z }); Establezca la nueva velocidad lineal del jugador en función de la dirección de movimiento calculada y mantenga la velocidad vertical actual (para no afectar los saltos o caídas).
Como resultado, al presionar las teclas WASD , el personaje comenzó a moverse por la escena. También puede interactuar con el cubo, porque ambos son objetos físicos.
Para implementar el salto, usemos la funcionalidad de los paquetes @dimforge/rapier3d-compat y @react-tres/rapier . En este ejemplo, comprobemos que el personaje está en el suelo y que se ha pulsado la tecla de salto. En este caso, establecemos la dirección del personaje y la fuerza de aceleración en el eje Y.
Para Player agregaremos masa y bloquearemos la rotación en todos los ejes, para que no se caiga en diferentes direcciones al chocar con otros objetos en la escena.
Explicación del código:
- mundo constante = estoque.mundo; Obteniendo acceso a la escena del motor de física Rapier . Contiene todos los objetos físicos y gestiona su interacción.
- const ray = world.castRay(new RAPIER.Ray(playerRef.current.translation(), { x: 0, y: -1, z: 0 })); Aquí es donde tiene lugar el "raycasting" (raycasting). Se crea un rayo que comienza en la posición actual del jugador y apunta hacia el eje y. Este rayo se "proyecta" en la escena para determinar si se cruza con algún objeto en la escena.
- const conectado a tierra = rayo && ray.collider && Math.abs(ray.toi) <= 1,5; La condición se verifica si el jugador está en el suelo:
- rayo : si se creó el rayo ;
- ray.collider : si el rayo chocó con algún objeto en la escena;
- Math.abs(ray.toi) : el "tiempo de exposición" del rayo. Si este valor es menor o igual al valor dado, puede indicar que el jugador está lo suficientemente cerca de la superficie como para ser considerado "en el suelo".
También es necesario modificar el componente Tierra para que el algoritmo de trazado de rayos para determinar el estado de "aterrizaje" funcione correctamente, agregando un objeto físico que interactuará con otros objetos en la escena.
Levantemos la cámara un poco más para ver mejor la escena.
Código de sección
Para mover la cámara, obtendremos la posición actual del reproductor y cambiaremos la posición de la cámara cada vez que se actualice el fotograma. Y para que el personaje se mueva exactamente a lo largo de la trayectoria hacia donde apunta la cámara, necesitamos agregar applyEuler .
Explicación del código:
El método applyEuler aplica rotación a un vector basándose en ángulos de Euler específicos. En este caso, la rotación de la cámara se aplica al vector de dirección . Esto se utiliza para hacer coincidir el movimiento relativo a la orientación de la cámara, de modo que el jugador se mueva en la dirección en la que se gira la cámara.
Ajustemos ligeramente el tamaño de Player y hagámoslo más alto en relación con el cubo, aumentando el tamaño de CapsuleCollider y arreglando la lógica de "salto".
Código de sección
Para que la escena no parezca completamente vacía, agreguemos generación de cubos. En el archivo json, enumere las coordenadas de cada uno de los cubos y luego muéstrelas en la escena. Para hacer esto, cree un archivo cubes.json , en el que enumeraremos una matriz de coordenadas.
[ [0, 0, -7], [2, 0, -7], [4, 0, -7], [6, 0, -7], [8, 0, -7], [10, 0, -7] ]
En el archivo Cube.jsx , cree un componente Cubes , que generará cubos en un bucle. Y el componente Cube será un objeto generado directamente.
import {RigidBody} from "@react-three/rapier"; import cubes from "./cubes.json"; export const Cubes = () => { return cubes.map((coords, index) => <Cube key={index} position={coords} />); } const Cube = (props) => { return ( <RigidBody {...props}> <mesh castShadow receiveShadow> <meshStandardMaterial color="white" /> <boxGeometry /> </mesh> </RigidBody> ); }
Agreguemos el componente Cubos creado al componente Aplicación eliminando el cubo único anterior.
Ahora agreguemos un modelo 3D a la escena. Agreguemos un modelo de arma para el personaje. Empecemos buscando un modelo 3D. Por ejemplo, tomemos este .
Descargue el modelo en formato GLTF y descomprima el archivo en la raíz del proyecto.
Para obtener el formato que necesitamos para importar el modelo a la escena, necesitaremos instalar el paquete complementario gltf-pipeline .
npm i -D gltf-pipeline
Usando el paquete gltf-pipeline , reconvierta el modelo del formato GLTF al formato GLB , ya que en este formato todos los datos del modelo se colocan en un solo archivo. Como directorio de salida para el archivo generado especificamos la carpeta pública .
gltf-pipeline -i weapon/scene.gltf -o public/weapon.glb
Luego necesitamos generar un componente de reacción que contendrá el marcado de este modelo para agregarlo a la escena. Utilicemos el recurso oficial de los desarrolladores de @react-tres/fiber .
Para ir al convertidor será necesario cargar el archivo arma.glb convertido.
Usando arrastrar y soltar o la búsqueda del Explorador, busque este archivo y descárguelo.
En el convertidor veremos el componente de reacción generado, cuyo código transferiremos a nuestro proyecto en un nuevo archivo WeaponModel.jsx , cambiando el nombre del componente por el mismo nombre que el archivo.
Ahora importemos el modelo creado a la escena. En el archivo App.jsx agregue el componente WeaponModel .
En este punto de nuestra escena, ninguno de los objetos proyecta sombras.
Para habilitar sombras en la escena, debe agregar el atributo de sombras al componente Canvas .
A continuación, debemos agregar una nueva fuente de luz. A pesar de que ya tenemos luz ambiental en escena, no puede crear sombras para los objetos porque no tiene un haz de luz direccional. Entonces, agreguemos una nueva fuente de luz llamada direccionalLight y configurémosla. El atributo para habilitar el modo de sombra " proyectar " es castShadow . Es la adición de este parámetro lo que indica que este objeto puede proyectar una sombra sobre otros objetos.
Después de eso, agreguemos otro atributo recibir Sombra al componente Tierra , lo que significa que el componente en la escena puede recibir y mostrar sombras sobre sí mismo.
Se deberían agregar atributos similares a otros objetos en la escena: cubos y jugador. Para los cubos agregaremos castShadow yceivedShadow , porque pueden proyectar y recibir sombras, y para el jugador agregaremos solo castShadow .
Agreguemos castShadow para Player .
Agregue castShadow y recibaShadow para Cube .
Si miras de cerca ahora, encontrarás que el área de superficie sobre la que se proyecta la sombra es bastante pequeña. Y al ir más allá de esta zona, la sombra simplemente se corta.
La razón de esto es que, de forma predeterminada, la cámara captura solo una pequeña área de las sombras mostradas desde direccionalLight . Podemos usar el componente direccionalLight agregando atributos adicionales de cámara de sombra (arriba, abajo, izquierda, derecha) para expandir esta área de visibilidad. Después de agregar estos atributos, la sombra se volverá ligeramente borrosa. Para mejorar la calidad, agregaremos el atributo Shadow-MapSize .
Ahora agreguemos la visualización de armas en primera persona. Cree un nuevo componente de arma , que contendrá la lógica de comportamiento del arma y el modelo 3D en sí.
import {WeaponModel} from "./WeaponModel.jsx"; export const Weapon = (props) => { return ( <group {...props}> <WeaponModel /> </group> ); }
Coloquemos este componente en el mismo nivel que el RigidBody del personaje y en el gancho useFrame estableceremos la posición y el ángulo de rotación en función de la posición de los valores de la cámara.
Para que el andar del personaje sea más natural, agregaremos un ligero movimiento del arma mientras se mueve. Para crear la animación usaremos la biblioteca tween.js instalada.
El componente Arma se incluirá en una etiqueta de grupo para que pueda agregarle una referencia a través del gancho useRef .
Agreguemos algo de useState para guardar la animación.
Creemos una función para inicializar la animación.
Explicación del código:
- const twSwayingAnimation = new TWEEN.Tween(currentPosition) ... Creando una animación de un objeto "balanceándose" desde su posición actual a una nueva posición.
- const twSwayingBackAnimation = new TWEEN.Tween(currentPosition) ... Creando una animación del objeto que regresa a su posición inicial después de que se haya completado la primera animación.
- twSwayingAnimation.chain(twSwayingBackAnimation); Conectar dos animaciones para que cuando se complete la primera animación, la segunda animación comience automáticamente.
En useEffect llamamos a la función de inicialización de la animación.
Ahora es necesario determinar el momento durante el cual se produce el movimiento. Esto se puede hacer determinando el vector actual de dirección del personaje.
Si se produce movimiento del personaje, actualizaremos la animación y la ejecutaremos nuevamente cuando termine.
Explicación del código:
- const isMoving = dirección.longitud() > 0; Aquí se comprueba el estado de movimiento del objeto. Si el vector de dirección tiene una longitud mayor que 0, significa que el objeto tiene una dirección de movimiento.
- if (isMoving && isSwayingAnimationFinished) { ... } Este estado se ejecuta si el objeto se está moviendo y la animación de "oscilación" ha finalizado.
En el componente de la aplicación , agreguemos un useFrame donde actualizaremos la animación de interpolación.
TWEEN.update() actualiza todas las animaciones activas en la biblioteca TWEEN.js . Este método se llama en cada cuadro de animación para garantizar que todas las animaciones se ejecuten sin problemas.
Código de sección:
Necesitamos definir el momento en que se dispara un tiro, es decir, cuando se presiona el botón del mouse. Agreguemos useState para almacenar este estado, useRef para almacenar una referencia al objeto arma y dos controladores de eventos para presionar y soltar el botón del mouse.
Implementemos una animación de retroceso al hacer clic con el botón del mouse. Usaremos la biblioteca tween.js para este propósito.
Definamos constantes para la fuerza de retroceso y la duración de la animación.
Al igual que con la animación de movimiento del arma, agregamos dos estados useState para la animación de retroceso y regreso a la posición inicial y un estado con el estado final de la animación.
Creemos funciones para obtener un vector aleatorio de animación de retroceso: generateRecoilOffset y generateNewPositionOfRecoil .
Crea una función para inicializar la animación de retroceso. También agregaremos useEffect , en el que especificaremos el estado de "disparo" como una dependencia, de modo que en cada disparo la animación se inicialice nuevamente y se generen nuevas coordenadas finales.
Y en useFrame , agreguemos una marca para "mantener presionada" la tecla del mouse para disparar, de modo que la animación de disparo no se detenga hasta que se suelte la tecla.
Realiza la animación de "inactividad" del personaje, para que no haya sensación de que el juego está "colgado".
Para hacer esto, agreguemos algunos estados nuevos mediante useState .
Arreglemos la inicialización de la animación de "meneo" para usar valores del estado. La idea es que diferentes estados: caminar o detenerse, usarán diferentes valores para la animación y cada vez la animación se inicializará primero.
En esta parte hemos implementado la generación de escenas y el movimiento de personajes. También agregamos un modelo de arma, animación de retroceso al disparar y al ralentí. En la siguiente parte continuaremos perfeccionando nuestro juego, agregando nuevas funciones.
También publicado aquí .