Let's look at animating a sprite sheet, on an HTML5 canvas, using JavaScript.
A Little Setup
First, let's create the canvas element.
<canvas width="300" height="200"></canvas>
Add a border (so we can see our usable area).
canvas {
border: 1px solid black;
}
And load the sprite sheet (https://opengameart.org/content/green-cap-character-16x18). While we're at it, let's get access to the canvas and its 2D context.
let img = new Image();
img.src = 'https://opengameart.org/sites/default/files/Green-Cap-Character-16x18.png';
img.onload = function() {
init();
};
let canvas = document.querySelector('canvas');
let ctx = canvas.getContext('2d');
function init() {
// future animation code goes here
}
The init
function is called after the image is loaded, via img.onload
. This is to ensure the image is loaded before we try working with it. All of animation code will go in the init
function. For the sake of this tutorial, this will work. If we were dealing with multiple images, we'd probably want to use Promises to wait for all of them to load before doing anything with them.
The Spritesheet
Now that we're set up, let's take a look at the image.
Each row represents and animation cycle. The first (top) row is the character walking in a downward direction, the second row is walking up, the third row is walking left, and the fourth (bottom) row is walking right. Technically, the left column is a standing (no animation) while the middle and right columns are animation frames. I think we can use all three for a smoother walking animation, though. 😊
Context's drawImage
Method
Before we get to animating our image, let's look at the drawImage
context method, as that's what we'll use for automatically slicing up the sprite sheet and applying it to our canvas.
Whoa, there are a lot of parameters in that method! Especially the third form, which is the one we'll be using. Don't worry, it's not as bad as it seems. There's a logical grouping to it.
drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight)
The image
argument is the source image. The next four (sx
, sy
, sWidth
, and sHeight
) relate to the source image - the sprite sheet. The last four (dx
, dy
, dWidth
, and dHeight
) relate to the destination - the canvas.
The "x" and "y" parameters (sx
, sy
, dx
, dy
) relate to the sprite sheet (source) and canvas (destination) starting positions, respectively. It's essentially a grid, where the top left starts at (0, 0) and moves positively to the right and down. In other words, (50, 30) is 50 pixels to the right and 30 pixels down.
The "Width" and "Height" parameters (sWidth
, sHeight
, dWidth
, and dHeight
) refer to the width and height of the sprite sheet and canvas, starting at their respective "x" and "y" positions. Let's break it down to one section, say the source image. If the source parameters (sx
, sy
, sWidth
, sHeight
) are (10, 15, 20, 30), the the starting position (in grid coordinates) would be (10, 15) and stretch to (30, 45). Then ending coordinates are calculated as (sx
+ sWidth
, sy
+ sHeight
).
Drawing The First Frame
Now that we've gone over the drawImage
method, let's actually see it in action.
Our sprite sheet's character frame size is conveniently labeled in the file name (16x18
), so that gives us our width and height attributes. The first frame will start at (0, 0) and end at (16, 18). Let's draw that to the canvas. We'll start with drawing this frame starting at (0, 0) on the canvas and keep the proportions.
function init() {
ctx.drawImage(img, 0, 0, 16, 18, 0, 0, 16, 18);
}
And we have our first frame! It's a little small though. Let's scale it up a bit to make it easier to see.
Change the above to this:
const scale = 2;
function init() {
ctx.drawImage(img, 0, 0, 16, 18, 0, 0, 16 * scale, 18 * scale);
}
You should see the image drawn on the canvas has doubled in size both horizontally and vertically. By changing the dWidth
and dHeight
values, we can scale the original image to be smaller or larger on the canvas. Be careful when doing this though, as you're dealing with pixels, it can start blurring pretty quickly. Try changing the scale
value and see how the output is changed.
Next Frames
To draw a second frame, the only thing we need to do is change some values for the source set. Specifically, sx
and sy
. The width and height of each frame are the same, so we'll never have to change those values. In fact, let's pull those values out, create a couple scaled values, and draw our next two frames to the right of our current frame.
const scale = 2;
const width = 16;
const height = 18;
const scaledWidth = scale * width;
const scaledHeight = scale * height;
function init() {
ctx.drawImage(img, 0, 0, width, height, 0, 0, scaledWidth, scaledHeight);
ctx.drawImage(img, width, 0, width, height, scaledWidth, 0, scaledWidth, scaledHeight);
ctx.drawImage(img, width * 2, 0, width, height, scaledWidth * 2, 0, scaledWidth, scaledHeight);
}
And this is what it looks like now:
Now we have the entire top row of the sprite sheet, but in three separate frames. If you look at the ctx.drawImage
calls, there are only 4 values that change now - sx
, sy
, dx
, and dy
.
Let's simplify it a bit. While we're at it, let's start using frame numbers from the sprite sheet instead of dealing with pixels.
Replace all the ctx.drawImage
calls with this:
function drawFrame(frameX, frameY, canvasX, canvasY) {
ctx.drawImage(img,
frameX * width, frameY * height, width, height,
canvasX, canvasY, scaledWidth, scaledHeight);
}
function init() {
drawFrame(0, 0, 0, 0);
drawFrame(1, 0, scaledWidth, 0);
drawFrame(0, 0, scaledWidth * 2, 0);
drawFrame(2, 0, scaledWidth * 3, 0);
}
Our drawFrame
function handles the sprite sheet math, so we only need to pass in frame numbers (starting at 0, like an array, so the "x" frames are 0, 1, and 2).
The canvas "x" and "y" values still take pixel values so we have better control over positioning the character. Moving the scaledWidth
multiplier inside the function (i.e. scaledWidth * canvasX
) would mean everything moves/changes an entire scaled character width at a time. That wouldn't work with a walking animation if, say, the character moves 4 or 5 pixels each frame. So we leave that as it is.
There's also an extra line in that list of drawFrame
calls. This is to show what our animation cycle will look like, rather than just drawing the top three frames of the sprite sheet. Instead of the animation cycle repeating "left step, right step", it will repeat "stand, left, stand, right" - it's a slightly better animation cycle. Either is fine though - a number of games in the 80s used two step animations.
This is where we're currently at:
Let's Animate This Character!
Now we're ready to animate our character! Let's take a look at requestAnimationFrame
in the MDN docs.
This is what we'll use to create our loop. We could also use setInterval
, but requestAnimationFrame
has some nice optimizations in place already, like running at 60 frames per second (or as close as it can) and stopping the animation loop when the browser/tab loses focus.
Essentially, the requestAnimationFrame
is a recursive function - to create our animation loop, we'll call requestAnimationFrame
again from the function we're passing as the argument. Something like this:
window.requestAnimationFrame(step);
function step() {
// do something
window.requestAnimationFrame(step);
}
The lone call before the walk
function starts the loop, then it's continuously called within.
Before we get to using it, there's one other context method we need to know and use - clearRect
(MDN docs). When drawing to the canvas, if we keep calling drawFrame
on the same position, it'll keep drawing on top of what's already there. For simplicity, we'll clear the entire canvas between each draw, rather than just the area we draw to.
So, our draw loop will look something like clear, draw the first frame, clear, draw the second frame, and so on.
In other words:
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawFrame(0, 0, 0, 0);
// repeat for each frame
Okay, let's animate this character! Let's create an array for the cycle loop (0, 1, 0, 2) and something to keep track of where we are in that cycle. Then we'll create our step
function, which will act as the main animation loop.
The step function clears the canvas, draws the frame, advances (or resets) our position in the cycle loop, then calls itself via requestAnimationFrame
.
const cycleLoop = [0, 1, 0, 2];
let currentLoopIndex = 0;
function step() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawFrame(cycleLoop[currentLoopIndex], 0, 0, 0);
currentLoopIndex++;
if (currentLoopIndex >= cycleLoop.length) {
currentLoopIndex = 0;
}
window.requestAnimationFrame(step);
}
And to get the animation started, let's update the init
function.
function init() {
window.requestAnimationFrame(step);
}
That character is going places fast! 😂
Slow Down There!
Looks like our character is a little out of control. If the browser allows it, the character will be drawn 60 frames per second, or as close as possible. Let's put a limit on that so it's stepping every 15 frames. We'll need to keep track of which frame we're on. Then, in the step
function, we'll advance the counter every call, but only draw after 15 frames pass. Once 15 frames pass, reset the counter, and draw the frame.
const cycleLoop = [0, 1, 0, 2];
let currentLoopIndex = 0;
let frameCount = 0;
function step() {
frameCount++;
if (frameCount < 15) {
window.requestAnimationFrame(step);
return;
}
frameCount = 0;
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawFrame(cycleLoop[currentLoopIndex], 0, 0, 0);
currentLoopIndex++;
if (currentLoopIndex >= cycleLoop.length) {
currentLoopIndex = 0;
}
window.requestAnimationFrame(step);
}
Much better!
The Other Directions
So far, we've only handled the down direction. How about we modify the animation a bit so the character does a complete 4-step cycle in each direction?
Remember, the "down" frames are in row 0 in our code (first row of the sprite sheet), up is row 1, left is row 2, and right is row 3 (bottom row of the sprite sheet). The cycle remains 0, 1, 0, 2 for each row. Since we're already handling the cycle changes, the only thing we need to change is the row number, which is the second parameter of the drawFrame
function.
We'll add a variable to keep track of our current direction. To keep it simple, we'll go in the sprite sheet's order (down, up, left, right) so it's sequential (0, 1, 2, 3, repeat).
When the cycle resets, we'll move to the next direction. And once we've gone through every direction, we'll start over. So, our updated step
function and associated variables look like this:
const cycleLoop = [0, 1, 0, 2];
let currentLoopIndex = 0;
let frameCount = 0;
let currentDirection = 0;
function step() {
frameCount++;
if (frameCount < 15) {
window.requestAnimationFrame(step);
return;
}
frameCount = 0;
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawFrame(cycleLoop[currentLoopIndex], currentDirection, 0, 0);
currentLoopIndex++;
if (currentLoopIndex >= cycleLoop.length) {
currentLoopIndex = 0;
currentDirection++; // Next row/direction in the sprite sheet
}
// Reset to the "down" direction once we've run through them all
if (currentDirection >= 4) {
currentDirection = 0;
}
window.requestAnimationFrame(step);
}
And there we have it! Our character is walking in all four directions, animated all from a single image.
Top comments (12)
Yes, Marty!
I literally started building something yesterday and needed to use sprite sheets but I got a bit stuck and just decided to animate a round div instead! Lol
This is literally gold for me right now, man! Thanks for writing this up!
If I get this finished I'll show you the final product!
Thanks again! ✌🏻😁
Glad I could help! I look forward to seeing your project!
Great tut Martin. Am just learning.
Can I ask... how to stop the looping? Say you want the animation to complete only one cycle?
requestAnimationFrame
returns a long integer. If you assign that to a variable, you can then callcancelAnimationFrame(id)
once the required condition is met to stop the cycle.Using the
step
function as an example, something like this:Another way could be to return early to break out of the
step
function. If only one cycle is needed, once the counter reaches the end of the array, return before calling the nextrequestAnimationFrame
(instead of reseting the counter to 0 and continuing).Here are the MDN pages for requestAnimationFrame and cancelAnimationFrame.
Thank you... I really appreciate your help.
Thank you so much for this tutorial. It was my first exposure to making sprite animation but your walk through/examples was very clear. :)
CSS3 animations using steps(n) will work in most modern browsers, too.
Good to know. I haven't done much with CSS animations yet. Thanks!
This is incredible! Thanks for the super clear and thorough explanation.
Nice thanks
Thank you so much mr Himmel.
Nice article, I also cover this topic if someone want another point of view.
HTML5 canvas - part 1: Drawing
Guillaume Martigny