Animations and tweens
This is the 9th step out of 11 of the creating a Breakout game using pure JavaScript tutorial. We'll explore how to implement animations and tweens in our game, to make the game look more juicy and alive. This will result in a better, more entertaining experience.
Animations
A sprite animation involves taking a spritesheet and displaying the sprites sequentially. As an example, we will make the ball wobble when it hits something.
First of all, grab the spritesheet and save it in your /img directory.
Replace the ball's image URL with the spritesheet URL:
const ball = new Ball("img/wobble.png", ctx);
In the Ball class, add an explicit size so that GameObject.preload() doesn't use the dimensions of the entire spritesheet as the ball's size. Also add properties for the frame sequence and the time elapsed since the animation started:
class Ball extends GameObject {
size = { w: 20, h: 20 };
wobbleFrames = [0, 1, 0, 2, 0, 1, 0, 2, 0];
wobbleTime = null;
// ... existing properties and methods ...
}
The frame sequence refers to the three sprites by their positions: 0, 1, and 2. A wobbleTime of null means no animation is playing, so we display frame 0.
Playing the animation
Add the following methods to Ball, keeping its existing move() and onCollide() methods:
class Ball extends GameObject {
// ...
playWobble() {
this.wobbleTime = 0;
}
updateAnimation(dt) {
if (this.wobbleTime === null) {
return;
}
this.wobbleTime += dt;
if (this.wobbleTime >= this.wobbleFrames.length * (1 / 24)) {
this.wobbleTime = null;
}
}
draw() {
const frame =
this.wobbleTime === null
? 0
: this.wobbleFrames[Math.floor(this.wobbleTime / (1 / 24))];
const { left, top } = this.hitbox;
this.ctx.drawImage(
this.asset,
frame * this.size.w,
0,
this.size.w,
this.size.h,
left,
top,
this.size.w,
this.size.h,
);
}
}
The playWobble() method starts the animation, or restarts it if it's already playing. The updateAnimation() method advances it using elapsed time in seconds. We play the animation at 24 frames per second, so each frame lasts 1 / 24 seconds.
The overridden draw() method uses the nine-argument form of ctx.drawImage(). The first four numbers after the image select a rectangle from the spritesheet; the last four position and size that rectangle on the canvass.
Applying the animation when the ball hits the paddle
Add an onCollide() method to Paddle to start the ball's animation when the collision system reports a hit:
class Paddle extends GameObject {
// ...
onCollide() {
ball.playWobble();
}
}
The animation is played every time the ball hits the paddle. You can call ball.playWobble() inside the brick's onCollide() method too, if you feel it would make the game look better.
Tweens
Whereas sprite animations display frames sequentially, tweens smoothly animate properties of an object in the gameworld, such as width or opacity.
Let's add a tween to our game to make the bricks smoothly disappear when they are hit by the ball. We still need to remove each brick from bricks and colliders immediately after it's hit so it cannot be hit or scored again. To keep drawing it during the tween, add a separate array alongside bricks
const disappearingBricks = [];
Replace the Brick class with the following:
class Brick extends GameObject {
shrinkTime = 0;
constructor(url, ctx, x, y, w, h) {
super(url, ctx);
this.pos = { x, y };
this.size = { w, h };
}
draw() {
const scale = 1 - Math.min(this.shrinkTime / 0.2, 1);
const width = this.size.w * scale;
const height = this.size.h * scale;
this.ctx.drawImage(
this.asset,
this.pos.x - width / 2,
this.pos.y - height / 2,
width,
height,
);
}
onCollide() {
bricks.splice(bricks.indexOf(this), 1);
colliders.splice(colliders.indexOf(this), 1);
disappearingBricks.push(this);
score += 10;
}
}
The shrinkTime property records how long the brick has been shrinking, in seconds. We want the tween to last for 0.2 seconds, so dividing it by 0.2 gives the progress from 0 to 1. Subtracting that progress from 1 gives a scale that decreases linearly from full size to zero. The drawing coordinates keep the shrinking image centered on the brick's original position.
Now, after the brick is removed from the gameplay, it's still kept track of within disappearingBricks so it can be animated.
Updating and drawing the effects
Replace update() with the following to advance both effects on every frame:
function update(timestamp) {
const dt = lastTimestamp === null ? 0 : (timestamp - lastTimestamp) / 1000;
lastTimestamp = timestamp;
ball.updateAnimation(dt);
for (let i = disappearingBricks.length - 1; i >= 0; i--) {
const brick = disappearingBricks[i];
brick.shrinkTime += dt;
if (brick.shrinkTime >= 0.2) {
disappearingBricks.splice(i, 1);
}
}
moveBall(dt);
// drawing code...
for (const brick of disappearingBricks) {
brick.draw();
}
drawStatus();
if (bricks.length === 0 && disappearingBricks.length === 0) {
alert("You won the game, congratulations!");
location.reload();
return;
}
requestAnimationFrame(update);
}
We advance existing effects before moving the ball, so animations started by collisions in this frame begin at time zero. We iterate disappearingBricks backwards when removing completed tweens, so removing items doesn't shift indices of elements we haven't visited yet. Drawing happens after these updates, and includes both intact and disappearing bricks.
The winning condition now waits for both arrays to be empty, so the last brick finishes shrinking before the winning message appears. The existing bricks.length > 0 condition in moveBall() stops the ball once all bricks have been hit, while the drawing loop continues to finish the tweens.
Compare your code
Here's what you should have so far, running live. To view its source code, click the "Play" button.
Next steps
Animations and tweens look very nice, but we can add even more to our game—in the next section we'll look at handling button inputs.