loading...

Moving a Sprite Sheet Character with JavaScript

martyhimmel profile image Martin Himmel ・7 min read

The long overdue follow up is finally here! 😅

In part 1, we covered how to animate a sprite sheet character on a timer using requestAnimationFrame. Now, instead of looping through a timed animation cycle, we'll change it to animate and move based on user input.

Setup

We'll be using the code from part 1 as a baseline. To make this a little easier, let's alter some of the old code to give us a better starting point.

let img = new Image();
img.src = 'https://opengameart.org/sites/default/files/Green-Cap-Character-16x18.png';
img.onload = function() {
  window.requestAnimationFrame(gameLoop);
};

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

const SCALE = 2;
const WIDTH = 16;
const HEIGHT = 18;
const SCALED_WIDTH = SCALE * WIDTH;
const SCALED_HEIGHT = SCALE * HEIGHT;

function drawFrame(frameX, frameY, canvasX, canvasY) {
  ctx.drawImage(img,
                frameX * WIDTH, frameY * HEIGHT, WIDTH, HEIGHT,
                canvasX, canvasY, SCALED_WIDTH, SCALED_HEIGHT);
}

const CYCLE_LOOP = [0, 1, 0, 2];
let currentLoopIndex = 0;
let frameCount = 0;
let currentDirection = 0;

function gameLoop() {

  window.requestAnimationFrame(gameLoop);
}
Enter fullscreen mode Exit fullscreen mode
  1. The init function has been renamed to gameLoop.
  2. The step function has been removed.
  3. To keep the loop going, window.requestAnimationFrame(gameLoop); is called at the end of gameLoop.
  4. In keeping with const conventions, all consts have been made fully upper case.

Getting User Input

Let's set up handling user input. We'll need a pair of event listeners to track when keys are pressed and released. We'll also need something to track those states. We could track specific buttons and only respond to those, or we can store all key presses in an object and later check what we need. Personally, I tend to use the latter.

let keyPresses = {};

window.addEventListener('keydown', keyDownListener, false);
function keyDownListener(event) {
  keyPresses[event.key] = true;
}

window.addEventListener('keyup', keyUpListener, false);
function keyUpListener(event) {
  keyPresses[event.key] = false;
}

function gameLoop() {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Moving the Character

Now that we're capturing user input, let's add the character back in and handle movement.

To start with, we'll only use the first frame of the down facing character. We also need to track the x and y positions of the character. We should also add a MOVEMENT_SPEED constant so we can easily change it later. This translates to the number of pixels moved per animation frame.

const MOVEMENT_SPEED = 1;
let positionX = 0;
let positionY = 0;

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

  if (keyPresses.w) {
    positionY -= MOVEMENT_SPEED;
  } else if (keyPresses.s) {
    positionY += MOVEMENT_SPEED;
  }
  if (keyPresses.a) {
    positionX -= MOVEMENT_SPEED;
  } else if (keyPresses.d) {
    positionX += MOVEMENT_SPEED;
  }

  drawFrame(0, 0, positionX, positionY);
  window.requestAnimationFrame(gameLoop);
}
Enter fullscreen mode Exit fullscreen mode

We have a moving character!

Note: The arrow keys were originally used, but due to the page scrolling when pressing up and down, the WASD keys were used instead. Any key combination will work though.

Changing Directions

Currently, the character always faces down. Let's handle facing different directions. As in part 1, we'll use the currentDirection variable to store which direction the character is facing. To make it a little more intuitive, let's add a constant for each direction.

const FACING_DOWN = 0;
const FACING_UP = 1;
const FACING_LEFT = 2;
const FACING_RIGHT = 3;
let currentDirection = FACING_DOWN;
Enter fullscreen mode Exit fullscreen mode

Now that that's set up, let's update the movement handling conditions and the drawFrame call to handle the set direction.

// Inside gameLoop
if (keyPresses.w) {
  positionY -= MOVEMENT_SPEED;
  currentDirection = FACING_UP;
} else if (keyPresses.s) {
  positionY += MOVEMENT_SPEED;
  currentDirection = FACING_DOWN;
}

if (keyPresses.a) {
  positionX -= MOVEMENT_SPEED;
  currentDirection = FACING_LEFT;
} else if (keyPresses.d) {
  positionX += MOVEMENT_SPEED;
  currentDirection = FACING_RIGHT;
}

drawFrame(0, currentDirection, positionX, positionY);
Enter fullscreen mode Exit fullscreen mode

And now we multiple directions. Let's add the different frames now. We'll still stick with the 0, 1, 0, 2 frame pattern for our walk animation. For that, we can bring back the reference to CYCLE_LOOP[currentLoopIndex] in our drawFrame call.

drawFrame(CYCLE_LOOP[currentLoopIndex], currentDirection, positionX, positionY);
Enter fullscreen mode Exit fullscreen mode

Then we can bring back the frame incrementor and limit. This looks a little different from part 1. We still need to handle movement, so instead of an early return, we'll increment the frame count, then every few frames reset the count and update the index. However, we only want the frame to increment if there's any movement.

const FRAME_LIMIT = 12;

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

  let hasMoved = false;

  if (keyPresses.w) {
    positionY -= MOVEMENT_SPEED;
    currentDirection = FACING_UP;
    hasMoved = true;
  } else if (keyPresses.s) {
    positionY += MOVEMENT_SPEED;
    currentDirection = FACING_DOWN;
    hasMoved = true;
  }

  if (keyPresses.a) {
    positionX -= MOVEMENT_SPEED;
    currentDirection = FACING_LEFT;
    hasMoved = true;
  } else if (keyPresses.d) {
    positionX += MOVEMENT_SPEED;
    currentDirection = FACING_RIGHT;
    hasMoved = true;
  }

  if (hasMoved) {
    frameCount++;
    if (frameCount >= FRAME_LIMIT) {
      frameCount = 0;
      currentLoopIndex++;
      if (currentLoopIndex >= CYCLE_LOOP.length) {
        currentLoopIndex = 0;
      }
    }
  }

  drawFrame(CYCLE_LOOP[currentLoopIndex], currentDirection, positionX, positionY);
  window.requestAnimationFrame(gameLoop);
}
Enter fullscreen mode Exit fullscreen mode

There we have it! The character moves around the canvas, changes directions, and cycles through all the animation frames.

A Little Cleanup

Before we continue, let's do a bit of refactoring to this:

const SCALE = 2;
const WIDTH = 16;
const HEIGHT = 18;
const SCALED_WIDTH = SCALE * WIDTH;
const SCALED_HEIGHT = SCALE * HEIGHT;
const CYCLE_LOOP = [0, 1, 0, 2];
const FACING_DOWN = 0;
const FACING_UP = 1;
const FACING_LEFT = 2;
const FACING_RIGHT = 3;
const FRAME_LIMIT = 12;
const MOVEMENT_SPEED = 1;

let canvas = document.querySelector('canvas');
let ctx = canvas.getContext('2d');
let keyPresses = {};
let currentDirection = FACING_DOWN;
let currentLoopIndex = 0;
let frameCount = 0;
let positionX = 0;
let positionY = 0;
let img = new Image();

window.addEventListener('keydown', keyDownListener);
function keyDownListener(event) {
    keyPresses[event.key] = true;
}

window.addEventListener('keyup', keyUpListener);
function keyUpListener(event) {
    keyPresses[event.key] = false;
}

function loadImage() {
  img.src = 'https://opengameart.org/sites/default/files/Green-Cap-Character-16x18.png';
  img.onload = function() {
    window.requestAnimationFrame(gameLoop);
  };
}

function drawFrame(frameX, frameY, canvasX, canvasY) {
  ctx.drawImage(img,
                frameX * WIDTH, frameY * HEIGHT, WIDTH, HEIGHT,
                canvasX, canvasY, SCALED_WIDTH, SCALED_HEIGHT);
}

loadImage();

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

  let hasMoved = false;

  if (keyPresses.w) {
    moveCharacter(0, -MOVEMENT_SPEED, FACING_UP);
    hasMoved = true;
  } else if (keyPresses.s) {
    moveCharacter(0, MOVEMENT_SPEED, FACING_DOWN);
    hasMoved = true;
  }

  if (keyPresses.a) {
    moveCharacter(-MOVEMENT_SPEED, 0, FACING_LEFT);
    hasMoved = true;
  } else if (keyPresses.d) {
    moveCharacter(MOVEMENT_SPEED, 0, FACING_RIGHT);
    hasMoved = true;
  }

  if (hasMoved) {
    frameCount++;
    if (frameCount >= FRAME_LIMIT) {
      frameCount = 0;
      currentLoopIndex++;
      if (currentLoopIndex >= CYCLE_LOOP.length) {
        currentLoopIndex = 0;
      }
    }
  }

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

function moveCharacter(deltaX, deltaY, direction) {
  positionX += deltaX;
  positionY += deltaY;
  currentDirection = direction;
}
Enter fullscreen mode Exit fullscreen mode

This looks a lot cleaner. The constants and variables are all in one place near the top (we could even move these to a set of objects instead of global scope, but for the sake of this tutorial, we'll keep it simple). The key press event listeners are the first in the set of functions. The image loader, which kicks off the whole game loop, is in its own function. And the movement handling has been moved to its own function.

Keeping In Bounds

Pulling the movement handling out to its own function actually has an additional purpose. Right now, the character can leave the canvas boundary. With the moveCharacter function, we can check for border collision in one place instead of four.

Our collision detection looks something like this:

  1. Does the character's left edge touch or pass the left edge of the canvas?
  2. Does the character's right edge touch or pass the right edge of the canvas?
  3. Does the character's top edge touch or pass the top edge of the canvas?
  4. Does the character's bottom edge touch or pass the bottom edge of the canvas?

If any of those are true, we need to stop the character from moving in the given direction. Since we're handling two directions at once, we can split the horizontal and vertical movement checks and restrictions. That way, if the character is at the middle of one edge, they can slide along that edge until they hit the corner.

Let's update our movement function to handle those conditions.

function moveCharacter(deltaX, deltaY, direction) {
  if (positionX + deltaX > 0 && positionX + SCALED_WIDTH + deltaX < canvas.width) {
    positionX += deltaX;
  }
  if (positionY + deltaY > 0 && positionY + SCALED_HEIGHT + deltaY < canvas.height) {
    positionY += deltaY;
  }
  currentDirection = direction;
}
Enter fullscreen mode Exit fullscreen mode

One important thing to remember is that positionX and positionY refer to the top left corner of the character. Because of that, positionX + SCALED_WIDTH gives us the right edge of the character, and positionX + SCALED_HEIGHT gives us the bottom edge of the character.

With that in mind, this is how the checks translate to match the questions above:

  1. positionX + deltaX > 0 checks for left edge collision.
  2. positionX + SCALED_WIDTH + deltaX < canvas.width checks for right edge collision.
  3. positionY + deltaY > 0 checks for top edge collision.
  4. positionY + SCALED_HEIGHT + deltaY < canvas.height checks for bottom edge collision.

One Last Quirk

Now that our character stays within bounds, there's one more little quirk to handle. If the user stops pressing a key when the character is on the second or fourth frame of the animation cycle, it looks a little odd. The character stands still in mid stride. How about we reset the frame when the character doesn't move?

In the gameLoop function, right before the call to drawFrame, let's add a check:

if (!hasMoved) {
    currentLoopIndex = 0;
}
Enter fullscreen mode Exit fullscreen mode

Great! Now the character will always be in a natural standing position when not moving.

Final Result

Here's the final bit of code:

const SCALE = 2;
const WIDTH = 16;
const HEIGHT = 18;
const SCALED_WIDTH = SCALE * WIDTH;
const SCALED_HEIGHT = SCALE * HEIGHT;
const CYCLE_LOOP = [0, 1, 0, 2];
const FACING_DOWN = 0;
const FACING_UP = 1;
const FACING_LEFT = 2;
const FACING_RIGHT = 3;
const FRAME_LIMIT = 12;
const MOVEMENT_SPEED = 1;

let canvas = document.querySelector('canvas');
let ctx = canvas.getContext('2d');
let keyPresses = {};
let currentDirection = FACING_DOWN;
let currentLoopIndex = 0;
let frameCount = 0;
let positionX = 0;
let positionY = 0;
let img = new Image();

window.addEventListener('keydown', keyDownListener);
function keyDownListener(event) {
    keyPresses[event.key] = true;
}

window.addEventListener('keyup', keyUpListener);
function keyUpListener(event) {
    keyPresses[event.key] = false;
}

function loadImage() {
  img.src = 'https://opengameart.org/sites/default/files/Green-Cap-Character-16x18.png';
  img.onload = function() {
    window.requestAnimationFrame(gameLoop);
  };
}

function drawFrame(frameX, frameY, canvasX, canvasY) {
  ctx.drawImage(img,
                frameX * WIDTH, frameY * HEIGHT, WIDTH, HEIGHT,
                canvasX, canvasY, SCALED_WIDTH, SCALED_HEIGHT);
}

loadImage();

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

  let hasMoved = false;

  if (keyPresses.w) {
    moveCharacter(0, -MOVEMENT_SPEED, FACING_UP);
    hasMoved = true;
  } else if (keyPresses.s) {
    moveCharacter(0, MOVEMENT_SPEED, FACING_DOWN);
    hasMoved = true;
  }

  if (keyPresses.a) {
    moveCharacter(-MOVEMENT_SPEED, 0, FACING_LEFT);
    hasMoved = true;
  } else if (keyPresses.d) {
    moveCharacter(MOVEMENT_SPEED, 0, FACING_RIGHT);
    hasMoved = true;
  }

  if (hasMoved) {
    frameCount++;
    if (frameCount >= FRAME_LIMIT) {
      frameCount = 0;
      currentLoopIndex++;
      if (currentLoopIndex >= CYCLE_LOOP.length) {
        currentLoopIndex = 0;
      }
    }
  }

  if (!hasMoved) {
    currentLoopIndex = 0;
  }

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

function moveCharacter(deltaX, deltaY, direction) {
  if (positionX + deltaX > 0 && positionX + SCALED_WIDTH + deltaX < canvas.width) {
    positionX += deltaX;
  }
  if (positionY + deltaY > 0 && positionY + SCALED_HEIGHT + deltaY < canvas.height) {
    positionY += deltaY;
  }
  currentDirection = direction;
}
Enter fullscreen mode Exit fullscreen mode

And this is the result:

Discussion

pic
Editor guide
Collapse
ggenya132 profile image
Eugene Vedensky

So, basically, game development is incredibly laborious and every frame of movement is small labor of love, got it!

But this is quite wonderful, thank you for sharing.

Collapse
leosidebo profile image
leosidebo

Very helpful.

Collapse
feddorra profile image
Serge Fedorenko

Thanks for your tutorial. This is really helpful!

Collapse
owmince1 profile image
Owmince

Hey what about diagonal move speed ? it's too fast

Collapse
martyhimmel profile image
Martin Himmel Author

The speed should be the same as the horizontal and vertical movements. If you need to slow it down, though, you could add a conditional multiplier so that when there's both horizontal and vertical movement, multiply the speed by 0.8 (or whatever you need).

Collapse
bradzo profile image
bradzo

Hi Martin
Good job!
Have you heard of the Phaser framework?
phaser.io

Collapse
martyhimmel profile image
Martin Himmel Author

Thanks!

Yeah, I used Phaser for a game prototype a couple years ago. It's a great framework - one I highly recommend for anyone looking to build a game in JS and not wanting to start from scratch.