41
loading...
This website collects cookies to deliver better user experience
index.html
with two canvas and two script elements:<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>✨ Fireworks in JavaScript</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<canvas id="background"></canvas>
<canvas id="firework"></canvas>
<script src="background.js"></script>
<script src="firework.js"></script>
</body>
</html>
styles.css
, that will only have two rules:body {
margin: 0;
}
canvas {
cursor: pointer;
position: absolute;
}
margin
on the body. It's also important to set canvas
elements to absolute
positioning, as we want to overlay them on top of each other.assets
folder, one for the wand, and one for the wizard. You can download them from the GitHub repository. With this in mind, this is how the project structure looks like:background.js
file, and set the canvas to take up the whole document with the following:(() => {
const canvas = document.getElementById('background');
const context = canvas.getContext('2d');
const width = window.innerWidth;
const height = window.innerHeight;
// Set canvas to fullscreen
canvas.width = width;
canvas.height = height;
})();
canvas
with getContext('2d')
. To create a gradient background, add the following function:const drawBackground = () => {
// starts from x, y to x1, y1
const background = context.createLinearGradient(0, 0, 0, height);
background.addColorStop(0, '#000B27');
background.addColorStop(1, '#6C2484');
context.fillStyle = background;
context.fillRect(0, 0, width, height);
};
createLinearGradient
method takes in the starting and end positions for the gradient. This means you can create a gradient in any direction.addColorStop
method as you want. Keep in mind, your offset (the first param) needs to be a number between 0 and 1, where 0 is the start and 1 is the end of the gradient. For example, to add a color stop at the middle at 50%, you would need to set the offset to 0.5.const drawForeground = () => {
context.fillStyle = '#0C1D2D';
context.fillRect(0, height * .95, width, height);
context.fillStyle = '#182746';
context.fillRect(0, height * .955, width, height);
};
height * 95%
). At this stage, you should have the following on the screen:assets
folder. To do that, add the below function to background.js
:const drawWizard = () => {
const image = new Image();
image.src = './assets/wizard.png';
image.onload = function () {
/**
* this - references the image object
* draw at 90% of the width of the canvas - the width of the image
* draw at 95% of the height of the canvas - the height of the image
*/
context.drawImage(this, (width * .9) - this.width, (height * .95) - this.height);
};
};
Image
object, set the source to the image you want to use, and wait for its load event before you draw it on the canvas. Inside the onload
event, this
references the Image
object. This is what you want to draw onto the canvas. The x
and y
coordinates for the image are decided based on the width
and height
of the canvas, as well as the dimensions of the image.const numberOfStars = 50;
const random = (min, max) => Math.random() * (max - min) + min;
const drawStars = () => {
let starCount = numberOfStars;
context.fillStyle = '#FFF';
while (starCount--) {
const x = random(25, width - 50);
const y = random(25, height * .5);
const size = random(1, 5);
context.fillRect(x, y, size, size);
}
};
while
loop. Although this is a small application, drawing to the screen, especially animating things is a computation heavy process. Because of this, I've chosen to use - at the writing of this article - the fastest loop in JavaScript. While this can be considered premature optimization, if you are writing a complete game or a computation heavy application, you want to minimize the amount of used resources.firework.js
and add a couple of variables here as well:(() => {
const canvas = document.getElementById('firework');
const context = canvas.getContext('2d');
const width = window.innerWidth;
const height = window.innerHeight;
const positions = {
mouseX: 0,
mouseY: 0,
wandX: 0,
wandY: 0
};
const image = new Image();
canvas.width = width;
canvas.height = height;
image.src = './assets/wand.png';
image.onload = () => {
attachEventListeners();
loop();
}
})();
canvas
element as for the background. A better way than this would be to have a separate file or function that handles setting up all canvases. That way, you won't have code duplication.positions
object that will hold the x
and y
coordinates both for the mouse as well as for the wand. This is where you also want to create a new Image
object. Once the image is loaded, you want to attach the event listeners as well as call a loop
function for animating the wand. For the event listener, you want to listen to the mousemove
event and set the mouse positions to the correct coordinates.const attachEventListeners = () => {
canvas.addEventListener('mousemove', e => {
positions.mouseX = e.pageX;
positions.mouseY = e.pageY;
});
};
loop
function, right now, only add these two lines:const loop = () => {
requestAnimationFrame(loop);
drawWand();
};
loop
function indefinitely and redraw the screen every frame. And where should you put your requestAnimationFrame
call? Should it be the first the or the last thing you call?requestAnimationFrame
at the top, it will run even if there's an error in the function.requestAnimationFrame
at the bottom, you can do conditionals to pause the animations.drawWand
function:const drawWand = () => {
positions.wandX = (width * .91) - image.width;
positions.wandY = (height * .93) - image.height;
const rotationInRadians = Math.atan2(positions.mouseY - positions.wandY, positions.mouseX - positions.wandX) - Math.PI;
const rotationInDegrees = (rotationInRadians * 180 / Math.PI) + 360;
context.clearRect(0, 0, width, height);
context.save(); // Save context to remove transformation afterwards
context.translate(positions.wandX, positions.wandY);
if (rotationInDegrees > 0 && rotationInDegrees < 90) {
context.rotate(rotationInDegrees * Math.PI / 180); // Need to convert back to radians
} else if (rotationInDegrees > 90 && rotationInDegrees < 275) {
context.rotate(90 * Math.PI / 180); // Cap rotation at 90° if it the cursor goes beyond 90°
}
context.drawImage(image, -image.width, -image.height / 2); // Need to position anchor to right-middle part of the image
// You can draw a stroke around the context to see where the edges are
// context.strokeRect(0, 0, width, height);
context.restore();
};
Math.atan2
at line:5. To convert this into degrees, you want to use the following equation:degrees = radians * 180 / Math.PI
save
the context to later restore
it at the end of the function. This is needed, otherwise the translate
and rotate
calls would add up. After saving the context, you can translate
it to the position of the wand.rotate
also expects radians. The if
statements are used for preventing the wand to be fully rotated around its axes.const fireworks = [];
const particles = [];
const numberOfParticles = 50; // keep in mind performance degrades with higher number of particles
const random = (min, max) => Math.random() * (max - min) + min;
const getDistance = (x1, y1, x2, y2) => {
const xDistance = x1 - x2;
const yDistance = y1 - y2;
return Math.sqrt(Math.pow(xDistance, 2) + Math.pow(yDistance, 2));
};
let mouseClicked = false;
d = √x² + y², where x = x1 - x2, and y = y1 - y2
attachEventListeners
function:const attachEventListeners = () => {
canvas.addEventListener('mousemove', e => {
positions.mouseX = e.pageX;
positions.mouseY = e.pageY;
});
canvas.addEventListener('mousedown', () => mouseClicked = true);
canvas.addEventListener('mouseup', () => mouseClicked = false);
};
function Firework() {
const init = () => {
// Construct the firework object
};
init();
}
firework
object, such as its coordinates, target coordinates, or color.const init = () => {
let fireworkLength = 10;
// Current coordinates
this.x = positions.wandX;
this.y = positions.wandY;
// Target coordinates
this.tx = positions.mouseX;
this.ty = positions.mouseY;
// distance from starting point to target
this.distanceToTarget = getDistance(positions.wandX, positions.wandY, this.tx, this.ty);
this.distanceTraveled = 0;
this.coordinates = [];
this.angle = Math.atan2(this.ty - positions.wandY, this.tx - positions.wandX);
this.speed = 20;
this.friction = .99; // Decelerate speed by 1% every frame
this.hue = random(0, 360); // A random hue given for the trail
while (fireworkLength--) {
this.coordinates.push([this.x, this.y]);
}
};
x
, y
, and tx
, ty
values will hold the initial and target coordinates. Initially, they will always equal to the position of the wand, and the position where the click occurred. Based on these values, we can use the getDistance
function we defined earlier to get the distance between the two points, and we will also need a property to keep track of the traveled distance.coordinates
, its angle
and speed
to calculate velocities, and a random color defined as hue
.Firework
function called draw
:this.draw = index => {
context.beginPath();
context.moveTo(this.coordinates[this.coordinates.length - 1][0],
this.coordinates[this.coordinates.length - 1][1]);
context.lineTo(this.x, this.y);
context.strokeStyle = `hsl(${this.hue}, 100%, 50%)`;
context.stroke();
this.animate(index);
};
// Animating the firework
this.animate = index => { ... }
index
from the fireworks
array and pass it down to the animate
method. To draw the trails, you want to draw a line from the very last coordinates
from the coordinates array, to the current x
and y
positions. For the color, we can use HSL notation, where we give it a random hue, 100% saturation, and 50% brightness.animate
method, add the following:this.animate = index => {
this.coordinates.pop();
this.coordinates.unshift([this.x, this.y]);
this.speed *= this.friction;
let vx = Math.cos(this.angle) * this.speed;
let vy = Math.sin(this.angle) * this.speed;
this.distanceTraveled = getDistance(positions.wandX, positions.wandY, this.x + vx, this.y + vy);
if(this.distanceTraveled >= this.distanceToTarget) {
let i = numberOfParticles;
while(i--) {
particles.push(new Particle(this.tx, this.ty));
}
fireworks.splice(index, 1);
} else {
this.x += vx;
this.y += vy;
}
};
coordinates
, and creates a new entry at the beginning of the array. By reassigning the speed
to friction
, it will also slow down the firework (by 1% each frame) as it reaches near its destination.x = cos(angle) * velocity
y = sin(angle) * velocity
x
and y
coordinates of the firework, as long as it didn't reach its final destination. If it did reach - which we can verify, by getting the distance between the wand and its current positions, including the velocities and checking it against the target distance - we want to create as many particles as we have defined at the beginning of the file. Don't forget to remove the firework from the array once it's exploded.loop
:if (mouseClicked) {
fireworks.push(new Firework());
}
let fireworkIndex = fireworks.length;
while(fireworkIndex--) {
fireworks[fireworkIndex].draw(fireworkIndex);
}
Firework
, every time the mouse is clicked. As long as the array is not empty, it will draw, and animate them.init
called Particle
.function Particle(x, y) {
const init = () => { ... };
init();
}
x
and y
coordinates as parameters. For the init
, we will have roughly the same properties as for fireworks
.const init = () => {
let particleLength = 7;
this.x = x;
this.y = y;
this.coordinates = [];
this.angle = random(0, Math.PI * 2);
this.speed = random(1, 10);
this.friction = 0.95;
this.gravity = 2;
this.hue = random(0, 360);
this.alpha = 1;
this.decay = random(.015, .03);
while(this.coordinateCount--) {
this.coordinates.push([this.x, this.y]);
}
};
x
and y
coordinates and assign a random angle
and speed
to each individual particle. random(0, Math.PI * 2)
will generate a random radian, with every possible direction.friction
and gravity
will slow down particles and makes sure they fall downwards. For colors, we can define a random hue
, and this time, an alpha
for transparency, and a decay
value, which is used to tell how fast each particle should fade out.draw
method, add the following lines:this.draw = index => {
context.beginPath();
context.moveTo(this.coordinates[this.coordinates.length - 1][0],
this.coordinates[this.coordinates.length - 1][1]);
context.lineTo(this.x, this.y);
context.strokeStyle = `hsla(${this.hue}, 100%, 50%, ${this.alpha})`;
context.stroke();
this.animate(index);
}
strokeStyle
also contains an alpha
value to fade out the particles over time.animate
method, you want a similar logic to fireworks
. Only this time, you don't need to worry about distances.this.animate = index => {
this.coordinates.pop();
this.coordinates.unshift([this.x, this.y]);
this.speed *= this.friction;
this.x += Math.cos(this.angle) * this.speed;
this.y += Math.sin(this.angle) * this.speed + this.gravity;
this.alpha -= this.decay;
if (this.alpha <= this.decay) {
particles.splice(index, 1);
}
}
coordinates
and adding a new one to the beginning of the array with unshift
. Then reassign speed
to slow each particle down over time, and don't forget to also apply velocities for the x
and y
coordinates. Lastly, the alpha
value can be decreased each frame until the particle is not visible anymore. Once it's invisible, it can be removed from the array. And to actually draw them, don't forget to add the same while
loop to the loop
function you have for the fireworks:let particleIndex = particles.length;
while (particleIndex--) {
particles[particleIndex].draw(particleIndex);
}