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);
}
- The
init
function has been renamed togameLoop
. - The
step
function has been removed. - To keep the loop going,
window.requestAnimationFrame(gameLoop);
is called at the end ofgameLoop
. - 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() {
// ...
}
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);
}
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;
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);
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);
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);
}
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;
}
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:
- Does the character's left edge touch or pass the left edge of the canvas?
- Does the character's right edge touch or pass the right edge of the canvas?
- Does the character's top edge touch or pass the top edge of the canvas?
- 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;
}
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:
-
positionX + deltaX > 0
checks for left edge collision. -
positionX + SCALED_WIDTH + deltaX < canvas.width
checks for right edge collision. -
positionY + deltaY > 0
checks for top edge collision. -
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;
}
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;
}
And this is the result:
Top comments (9)
So, basically, game development is incredibly laborious and every frame of movement is a small labor of love, got it!
But this is quite wonderful, thank you for sharing.
Thanks for your tutorial. This is really helpful!
Very helpful.
One of the best tutorials I've ever seen on dev.to.
Hey what about diagonal move speed ? it's too fast
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).
Hi Martin
Good job!
Have you heard of the Phaser framework?
phaser.io
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.
Nice way, helpful. Thanks.