DEV Community

Martin Himmel
Martin Himmel

Posted on • Updated on

Animating Sprite Sheets With JavaScript

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>
Enter fullscreen mode Exit fullscreen mode

Add a border (so we can see our usable area).

canvas {
  border: 1px solid black;
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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.

Character sprite sheet

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.

MDN docs - drawImage

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)
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

And to get the animation started, let's update the init function.

function init() {
  window.requestAnimationFrame(step);
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

And there we have it! Our character is walking in all four directions, animated all from a single image.

Top comments (12)

Collapse
 
adamthewizard profile image
Adam

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! ✌🏻😁

Collapse
 
martyhimmel profile image
Martin Himmel

Glad I could help! I look forward to seeing your project!

Collapse
 
deedeessay profile image
deedee resists!

Great tut Martin. Am just learning.
Can I ask... how to stop the looping? Say you want the animation to complete only one cycle?

Collapse
 
martyhimmel profile image
Martin Himmel

requestAnimationFrame returns a long integer. If you assign that to a variable, you can then call cancelAnimationFrame(id) once the required condition is met to stop the cycle.

Using the step function as an example, something like this:

let animationFrameId;

function step() {
    if (animationFrameId && conditionToStopCycle) {
        window.cancelAnimationFrame(animationFrameId);
        return;
    }
    // rest of the code
    animationFrameId = window.requestAnimationFrame(step);
}
Enter fullscreen mode Exit fullscreen mode

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 next requestAnimationFrame (instead of reseting the counter to 0 and continuing).

Here are the MDN pages for requestAnimationFrame and cancelAnimationFrame.

Collapse
 
deedeessay profile image
deedee resists!

Thank you... I really appreciate your help.

Collapse
 
sammygutierrez335 profile image
Sammy Gutierrez

Thank you so much for this tutorial. It was my first exposure to making sprite animation but your walk through/examples was very clear. :)

Collapse
 
lexlohr profile image
Alex Lohr

CSS3 animations using steps(n) will work in most modern browsers, too.

Collapse
 
martyhimmel profile image
Martin Himmel

Good to know. I haven't done much with CSS animations yet. Thanks!

Collapse
 
geraldosantos profile image
Geraldo dos Santos

This is incredible! Thanks for the super clear and thorough explanation.

Collapse
 
salvatore_ profile image
Salvatore_

Nice thanks

Collapse
 
mugammad profile image
Mugammad

Thank you so much mr Himmel.

Collapse
 
gmartigny profile image
Guillaume Martigny

Nice article, I also cover this topic if someone want another point of view.