Moving a Sprite Sheet Character with JavaScript
Tags: javascript, html, canvas
Published: 2019-02-25 01:49:41

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.

```js
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);
}
```

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.

```js
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() {
  // ...
}
```

### 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.

```js
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);
}
```

{% codepen https://codepen.io/martyhimmel/pen/omKKJy %}

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.

```js
const FACING_DOWN = 0;
const FACING_UP = 1;
const FACING_LEFT = 2;
const FACING_RIGHT = 3;
let currentDirection = FACING_DOWN;
```

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

```js
// 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);
```

{% codepen https://codepen.io/martyhimmel/pen/pYzgVj %}

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.

```js
drawFrame(CYCLE_LOOP[currentLoopIndex], currentDirection, positionX, positionY);
```

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_.

```js
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);
}
```

{% codepen https://codepen.io/martyhimmel/pen/mobrbX %}

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:

```js
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;
}
```

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.

```js
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;
}
```

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:

```js
if (!hasMoved) {
    currentLoopIndex = 0;
}
```

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

### Final Result

Here's the final bit of code:

```js
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;
}
```

And this is the result:

{% codepen https://codepen.io/martyhimmel/pen/drbpvZ %}