
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
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
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
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
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)
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í
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
& > 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
& > div:nth-of-type(n) {
position: absolute;
width: 8px;
height: 8px;
border-radius: 50%;
}
Luego volcamos el css
que hemos generado antes
${props => props.firework.shadows}
Y ya sólo nos quedan las animaciones
@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
@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 los2px
- 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
@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
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
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
& > 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ónposition
nos dura5s
, pero esa animación tiene 5 posiciones con lo que a cada posición dedica1s
, ya cuadra - En el segundo caso tenemos una duración de
1.25s
, que por cinco posiciones nos da un total de6.25s
, que es la duración que le ponemos aposition
- 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!
🙋♂️
Lista de correo: escribo algo de tanto en cuanto