Animate an 8 directional sprite in javascript

Codepen at bottom if you just want to look at the code without the walkthrough.

When animating a character for a game, you’ll usually be working with something called a “spritesheet”. Let’s take a look at the one we’re using for this tutorial:

8 keyframes per direction for 8 directions

So we have an 8x8 set of animation frames for a walking animation, at a pixel scale of 1. The columns each represent a different keyframe, and the rows represent different directions.

Let’s go through the pseudocode before we write any actual code. Our plan for animating this character is:

1. For a given direction, we want to loop through the keyframes and draw each one with a time delay before drawing the next one. 

Now that we know what we’re trying to achieve, let’s go through the elements we’ll need.

Set up

  1. Constants for the character’s dimensions and scale
//set scale of sprite
let SCALE = 4;
//define width and height based on source image
const WIDTH = 32;
const HEIGHT = 32;
const SCALED_WIDTH = SCALE * WIDTH;
const SCALED_HEIGHT = SCALE * HEIGHT;

2. An array of numbers corresponding to the number of keyframes.

//animation has 8 frames per row
const CYCLE_LOOP = [0,1,2,3,4,5,6,7];

3. Numbers corresponding to each direction assigned to constants representing those directions.

//rows of spritesheet
const SOUTH = 0;
const SOUTHEAST = 1;
const EAST = 2;
const NORTHEAST = 3;
const NORTH = 4;
const NORTHWEST = 5;
const WEST = 6;
const SOUTHWEST = 7;

4. A constant to represent the delay between keyframes, and one to represent the character’s movement speed

//Determines framerate of animation, lower numbers being faster
const FRAME_LIMIT = 4;
//Determines speed at which character moves
let MOVEMENT_SPEED = 0.5;

5. A canvas element to draw onto.

let canvas = document.querySelector('canvas');
let ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = false;

6. Variables for the current direction, current keyframe, how many frames have passed on current keyframe, character coordinates, mouse coordinates, whether the mouse is present, and an image variable so we can preload the spritesheet to avoid loading it every time we change animation frames.

//choose initial sprite
let currentDirection = SOUTHWEST;
let currentLoopIndex = 0;
let frameCount = 0;
//coordinates of character, based on top left corner of sprite
let positionX = 0;
let positionY = 0;
//coordinates of mouse
let mouseX;
let mouseY;
//start with mouse outside of canvas
let mousePresent = false;
//create a new image, later we will set the source as the spritesheet
let img = new Image();

Writing functions

Since we want to allow the character to move in any direction, we can’t just change the x and y position at a fixed rate, or else the character will move faster on the diagonals than on horizontals and verticals. So, we need to do a little math. We’ll call the function we write here, called ‘angleMath’, whenever we want to set the deltas, angle, and distance vector:

//difference between mouse and character's positions
let xD = 0;
let yD = 0;
//angle of the mouse relative to the character
let angle = 0;
//length of the vector from character to mouse
let hypotenuse = 0;

Let’s add event listeners so we can detect the mouse. We’ll call ‘angleMath’ whenever the mouse moves inside the canvas:

//Listen for mouse movement
canvas.addEventListener('mousemove', mouseMoveListener);
function mouseMoveListener(e) {
//get mouse coordinates within the canvas
mouseX=e.offsetX;
mouseY=e.offsetY;
angleMath();
}

In order to draw the character where we want, we’ll need to do a bit more math. Since we want the character to move in small increments and not travel the entire distance in an instant, we first reduce the delta X and Y distances by dividing by the vector distance from cursor to character. We then increment the x and y positions of the character by the deltas multiplied by the speed and scale variables. I added some basic collision code to prevent the character moving outside the borders, but you’ll want to use something different if you want to allow for more complex collision later on. Finally, we call angleMath again so that the character recalculates its angle to the target point as it moves. Without that, it will sometimes walk past the target point and continue in that direction forever.:

function moveCharacter() {
let deltaX = xD/hypotenuse
let deltaY = yD/hypotenuse
//collision with edge of canvas, doesn't accommodate collision with other objects inside canvas
if (positionX + deltaX >= 0 && positionX + SCALED_WIDTH + deltaX <= canvas.width) {
positionX += deltaX*MOVEMENT_SPEED*SCALE;
}
if (positionY + deltaY >= 0 && positionY + SCALED_HEIGHT + deltaY <= canvas.height) {
positionY += deltaY*MOVEMENT_SPEED*SCALE;
}
//calling the angle math here adjusts character's movement even if mouse stops moving
angleMath();
}

Now we need a function to actually draw the frames to the canvas. We’ll use the built-in function ‘drawImage’ to draw the frame onto the canvas’ context. The first argument is the spritesheet. The next two arguments represent which row and column we’re selecting the frame from. The next two dictate the size of the image to take from the source (the width and height of one frame), then the next two are the canvas dimensions, and finally the last two are the size that the image should be drawn onto the canvas.

//draw a specific frame from the spritesheet
function drawFrame(frameX, frameY, canvasX, canvasY) {
ctx.drawImage(img,
frameX * WIDTH, frameY * HEIGHT, WIDTH, HEIGHT,
canvasX, canvasY, SCALED_WIDTH, SCALED_HEIGHT);
}

Now we need a recursive function to first clear the canvas, then draw the correct frame, and repeat this process to create the animation.

First, clear the canvas to prepare it for drawing the next frame:

function drawLoop() {
ctx.clearRect(0, 0, canvas.width, canvas.height);

Next, choose the direction that the character should face based on the angle from the character to cursor:

//switch row of spritesheet for proper direction
switch (true) {
case (angle <= 22.5 && angle > -22.5):
//east
currentDirection = EAST;
break;
case (angle <= 67.5 && angle > 22.5):
//southeast
currentDirection = SOUTHEAST;
break;
case (angle <= 112.5 && angle > 67.5):
//south
currentDirection = SOUTH;
break;
case (angle <= 157.5 && angle > 112.5):
//southwest
currentDirection = SOUTHWEST;
break;
case (angle <= -157.5 || angle > 157.5):
//west
currentDirection = WEST;
break;
case (angle <= -112.5 && angle > -157.5):
//northwest
currentDirection = NORTHWEST;
break;
case (angle <= -67.5 && angle > -112.5):
//north
currentDirection = NORTH;
break;
case (angle <= -22.5 && angle > -67.5):
//northeast
currentDirection = NORTHEAST;
break;
}

Then check if the character should be moving or not. By setting a case of the hypotenuse of the coordinate deltas being less than or greater than a certain range, we can define the proximity to the cursor that defines when the character should stop moving:

let moving = false;
//character stops when touching mouse
switch(true) {
case (hypotenuse <= SCALED_WIDTH/4 || !mousePresent):
moving = false;
break;
case (hypotenuse > SCALED_WIDTH/4 && mousePresent):
moving = true;
break;
}

If moving, run the animation, and if not, set the currentLoopIndex to the first keyframe which should be the character just standing. To run the animation, we increment the frameCount until it has passed the necessary amount of time, then reset the frameCount to zero and increment the currentLoopIndex to progress to the next keyframe:

//run animation
if (moving) {
moveCharacter()
frameCount++;
if (frameCount >= FRAME_LIMIT/MOVEMENT_SPEED) {
frameCount = 0;
currentLoopIndex++;
if (currentLoopIndex >= CYCLE_LOOP.length) {
currentLoopIndex = 0;
}
}
}

if (!moving) {
currentLoopIndex = 0;
}

Finally, draw that frame to the canvas and call the function within itself so that the animation will keep going. We use the built-in function, ‘requestAnimationFrame’, to accomplish this. The main purpose of using this function is to ensure that custom animations made in javascript run smoothly in the browser.

drawFrame(CYCLE_LOOP[currentLoopIndex], currentDirection, positionX, positionY);
window.requestAnimationFrame(drawLoop);
}

Now we’ve reached the end. Write a function to load the spritesheet and initialize the animation, then call that function:

//load the spritesheet and run the animation
function runAnimation() {
img.src = 'https://i.imgur.com/3GHvoG3.png';
img.onload = function() {
window.requestAnimationFrame(drawLoop);
};
}
Play with the numbers for SCALE, FRAME_LIMIT, and MOVEMENT_SPEED to see how they change things.