hand Inicio
hand JSBloqs
hand GutenBloqs
Wallpaper

Simula fuegos artificiales con CSS

Unos simples efectos de fuegos artificiales con CSS ayudados con JavaScript que nos puede servir como punto de partida para creaciones más elaboradas

Prólogo

Los fuegos artificiales normalmente se basan en utilizar el elemento canvas, que es casi como otro lenguaje de programación

La alternativa es utilizar librerías externas como fireworks.js, threejs.org o Proton

Y hay otra alternativa mucho menos costosa para el navegador, que es hacerlo sólo con animaciones css

El ejemplo en que me he basado es éste, y aquí lo he reformulado con React y styled-components para generar estructuras algo más complejas y versátiles

Vamos allá

Problemas con las animaciones de css

Problemas? Qué problemas?

Pues uno mayúsculo, y es que no hay manera sencilla de volver a iniciar una animación css a través de JavaScript

Qué sería lo ideal?

Poder definir una animación por css, e inicializarla con un timer recurrente y cada vez en unas nuevas coordenadas, e incluso a cada repetición calcular otra distribución de fuegos

Pero para poder hacerlo tienes que tirar de hacks, como quitar y reañadir la clase (y no funciona con un rerender de React, o al menos a mi no me ha funcionado)

Por lo que la opción más sencilla es simplemente añadir otra animación que vaya cambiando las coordenadas de los fuegos, y poner todas las animaciones en un bucle infinito (y sincronizado entre sí)

Lo malo? Que ese cambio de coordenadas sigue un patrón y no es aleatorio

Lo bueno? Que aún y sabiendo que hay un patrón, al menos a mi me resulta imposible detectarlo, y es que aquí juegas a mezclar patrones y darles distinta cadencia

Código

jsx
import React from 'react'
import styled from 'styled-components'

// Helpers
const random = width => (Math.round((Math.random() * (width * 2) - width) * 100) / 100).toFixed(2)
const randomColour = () => '#000000'.replace(/0/g, () => (~~(Math.random() * 16)).toString(16))

// Función para devolver un grupo de fuegos artificiales en forma de box-shadow
const get_firework = (groups = 1, particles = 50, dim = 300) => {
// posiciones de partida
const box0 = Array(particles)
.fill()
.map(el => '0px 0px #fff')

// posiciones finales
const boxes = Array(groups)
.fill()
.map(el => {
const box1 = Array(particles)
.fill()
.map(el => `${random(dim)}px ${random(dim)}px ${randomColour()}`)
return box1.join(',')
})
// retornamos la posición de partida y la del final, tantas como fuegas hayamos creado
return [box0.join(','), ...boxes]
}

// Componente principal
const Tool = ({ pageContext }) => {
// definimos el número de fuegos independientes
const number_of_fireworks = 10

// recibimos los fuegos
const [start, ...end] = get_firework(number_of_fireworks)

// construímos el css final con los elementos anteriores
const shadows = end
.map(
(el, i) => `
& > div:nth-of-type(
${i + 1}) {
box-shadow:
${el};
animation:
${i / 4 + 1}s bang ease-out ${i / 4 + 1}s infinite backwards, ${i / 4 + 1}s gravity ease-in ${
i / 4 + 1
}
s infinite backwards,
${i / 4 + i + 5}s position linear ${i / 4 + 1}s infinite backwards;
}
`

)
.join('')

// retornamos el componente con tantos <div> internos como fuegos hayamos creado
return (
<Fireworks firework={{ start: start, shadows: shadows }}>
{Array(number_of_fireworks)
.fill()
.map((el, i) => (
<div key={`div${i}`}></div>
))}
</Fireworks>
)
}

export default Tool

// El componente principal
const Fireworks = styled.div`
& > div:nth-of-type(n) {
position: absolute;
width: 8px;
height: 8px;
border-radius: 50%;
}

/* aquí volcamos el css anterior */
${props => props.firework.shadows}

/* Simula la trayectoria después de la explosión */
@keyframes bang {
from {
box-shadow:
${props => props.firework.start};
}
}

/* Simula la caida con la gravedad */
@keyframes gravity {
to {
transform: translateY(200px);
width: 2px;
height: 2px;
opacity: 0;
}
}

/* Simula distintos focos de partida */
@keyframes position {
0%,
19.9% {
margin-top: 10%;
margin-left: 40%;
}

20%,
39.9% {
margin-top: 40%;
margin-left: 0%;
}

40%,
59.9% {
margin-top: 20%;
margin-left: 20%;
}

60%,
79.9% {
margin-top: 30%;
margin-left: 10%;
}

80%,
99.9% {
margin-top: 30%;
margin-left: 30%;
}
}
`

Y por partes, tenemos las funciones que nos dan valores random tanto en coordenadas como en colores

jsx
const random = width => (Math.round((Math.random() * (width * 2) - width) * 100) / 100).toFixed(2)
const randomColour = () => '#000000'.replace(/0/g, () => (~~(Math.random() * 16)).toString(16))

Luego tenemos la función que nos devuelve el concepto de fuego artificial

Y esto cómo lo ejecutamos?

Con un box-shadow, que utilizaremos simplemente para dibujar puntos en coordenadas distintas, y que luego animaremos con una transition al uso

jsx
const get_firework = (groups = 1, particles = 50, dim = 300) => {
// posiciones de partida
const box0 = Array(particles)
.fill()
.map(el => '0px 0px #fff')

// posiciones finales
const boxes = Array(groups)
.fill()
.map(el => {
const box1 = Array(particles)
.fill()
.map(el => `${random(dim)}px ${random(dim)}px ${randomColour()}`)
return box1.join(',')
})
return [box0.join(','), ...boxes]
}

Recibe 3 argumentos, el primero es el número de fuegos artificiales, el segundo el número de partículas, y el tercero es el espacio que limitará la distribución de puntos que nos dará la función random

Esta limitación nos dará un cuadrado, lo suyo sería implementar una función que no diera números totalmente aleatorios sinó que estuviese ponderada de algún modo para seguir una distribución más radial

Esto lo puedes ver si incrementas el número de partículas, pero vamos, para lo que busco aquí es más que suficiente

Entonces, lo que devuelve la función es un array donde el primer elemento será el punto de partida de todos los fuegos, que no es más que tener los mismos puntos con coordenadas cero y color blanco

Y el resto de elementos son las posiciones finales de tantos grupos como hayamos pedido, en formato box-shadow: ...

Con esto entramos ya en el componente en sí donde generamos el número de fuegos

jsx
const number_of_fireworks = 10
const [start, ...end] = get_firework(number_of_fireworks)

Y también construyo aquí el css (podría hacerlo en el propio componente de styled-components más abajo, cuestión de gustos)

jsx
const shadows = end
.map(
(el, i) => `
& > div:nth-of-type(
${i + 1}) {
box-shadow:
${el};
animation:
${i / 4 + 1}s bang ease-out ${i / 4 + 1}s infinite backwards, ${i / 4 + 1}s gravity ease-in ${
i / 4 + 1
}
s infinite backwards,
${i / 4 + i + 5}s position linear ${i / 4 + 1}s infinite backwards;
}
`

)
.join('')

Le pongo el join porque necesito generar un string con box-shadow, y el .map() me devuelve un array

Y en particular, si te fijas lo que hago es definir cada grupo de fuegos artificiales para cada <div> que vuelvo luego, aquí

jsx
return (
<Fireworks firework={{ start: start, shadows: shadows }}>
{Array(number_of_fireworks)
.fill()
.map((el, i) => (
<div key={`div${i}`}></div>
))}
</Fireworks>
)

Es decir, dentro de <Fireworks> tendre tantos <div> como fuegos artificiales haya pedido (con la constante number_of_fireworks)

Y el css de arriba lo que hace es apuntar a esos <div> con la expresión

jsx
& > div:nth-of-type(${i + 1}) {}

Luego vuelvo a esta función, de momento sigamos

Ya sólo nos queda definir los estilos y las animaciones

El común para todos los <div> anteriores es simplemente definir el punto en sí y la posicion absolute

jsx
  & > div:nth-of-type(n) {
position: absolute;
width: 8px;
height: 8px;
border-radius: 50%;
}

Luego volcamos el css que hemos generado antes

jsx
  ${props => props.firework.shadows}

Y ya sólo nos quedan las animaciones

jsx
  @keyframes bang {
from {
box-shadow: ${props => props.firework.start};
}
}

Aquí si te fijas sólo definimos el punto de partida, que será el mismo para todos, es decir todos los puntos con coordenadas cero y color blanco

Esta animación será la que nos dará el movimiento de los puntos desde el centro hasta su localización final

Seguimos con la gravedad

css
  @keyframes gravity {
to {
transform: translateY(200px);
width: 2px;
height: 2px;
opacity: 0;
}
}

Y si te fijas, aquí hacemos varias cosas

  • Nos movemos hacia abajo 200px
  • Reducimos el tamaño del punto desde los 8px hasta los 2px
  • Los vamos haciendo invisibles con el opacity

Aquí ya completamos el fuego artificial, pero nos queda la última animación que lo que hará será mover todo el fuego artificial

Será el mismo, pero nos dará la impresión de ser otro fuego que irá cambiando con el tiempo y también de frecuencia

css
  @keyframes position {
0%,
19.9%
{
margin-top: 10%;
margin-left: 40%;
}

20%,
39.9%
{
margin-top: 40%;
margin-left: 0%;
}

40%,
59.9%
{
margin-top: 20%;
margin-left: 20%;
}

60%,
79.9%
{
margin-top: 30%;
margin-left: 10%;
}

80%,
99.9%
{
margin-top: 30%;
margin-left: 30%;
}
}

Definimos las coordenadas con margin, y piensa que como estamos en position: absolute esos porcentajes son de todo el tamaño de la ventana

Bien, pues ahora volvemos a la función anterior porque de todo esto es lo que tiene más miga

jsx
const shadows = end
.map(
(el, i) => `
& > div:nth-of-type(
${i + 1}) {
box-shadow:
${el};
animation:
${i / 4 + 1}s bang ease-out ${i / 4 + 1}s infinite backwards, ${i / 4 + 1}s gravity ease-in ${
i / 4 + 1
}
s infinite backwards,
${i / 4 + i + 5}s position linear ${i / 4 + 1}s infinite backwards;
}
`

)
.join('')

Aquí se trata de hacer dos cosas

  • Crear el box-shadow que será lo que nos definirá cada fuego artificial
  • Desincronizar los grupos para que no se solapen entre ellos

Lo primero es sencillo y de hecho es lo que hacemos antes con get_firework(), que nos devuelve el array end donde cada elemento es un box-shadow concreto

Por lo tanto aquí lo que hacemos en realidad es configurar las animaciones

jsx
animation:
${i / 4 + 1}s bang ease-out ${i / 4 + 1}s infinite backwards,
${i / 4 + 1}s gravity ease-in ${i / 4 + 1}s infinite backwards,
${i / 4 + i + 5}s position linear ${i / 4 + 1}s infinite backwards;

Que es lo mismo que si pusiésemos manualmente

css
& > div:nth-of-type(1) {
animation: 1s bang ease-out 1s infinite backwards, 1s gravity ease-in 1s infinite backwards,
5s position linear 1s infinite backwards;
}
& > div:nth-of-type(2) {
animation: 1.25s bang ease-out 1.25s infinite backwards, 1.25s gravity ease-in 1.25s infinite backwards,
6.25s position linear 1.25s infinite backwards;
}
& > div:nth-of-type(3) {
animation: 1.5s bang ease-out 1.5s infinite backwards, 1.5s gravity ease-in 1.5s infinite backwards,
7.5s position linear 1.5s infinite backwards;
}

Y el quid es el cálculo de cuánto dura cada animación, para así mover la animación position cuando toque (y dar tiempo a que la animación termine)

  • En el primer caso tenemos una duración de 1s y la animación position nos dura 5s, pero esa animación tiene 5 posiciones con lo que a cada posición dedica 1s, ya cuadra
  • En el segundo caso tenemos una duración de 1.25s, que por cinco posiciones nos da un total de 6.25s, que es la duración que le ponemos a position
  • Y en el tercer caso lo mismo, con un total de 7.5s

Y así nos queda todo sincronizado, puedes cambiar estos valores para ver qué pasa cuando no está sincronizado

Listos!

🙋‍♂️

draw of me

Hola, tienes en mente desarrollar una web?

Si quieres, te ayudo

11ty para webs ultra-rápidaseleventy js
Gatsby para webs complejasgatsby js
WordPress para webs para el usuario finalwordpress

Escríbeme

Lista de correo: escribo algo de tanto en cuanto