Welcome back for another day of adventure! Today we will be adding a scoring mechanism as well as keeping track of lives. If you don't remember our last adventure (Adventures in TypeScript 04), we implemented the bricks that the ball smashes.
You can see the final outcome of the Breakout clone here. As well as see all the code in my GitHub repo.
Since we added bricks, we should give the player a purpose to smash them. Let's add a point system and output the score in a nice little hud. While we are at it, we should also take in account for when the ball resets because the paddle missed it. We are also going to implement lives into the hud. For starters, we are not going to use the iEntity interface we created back in the 2nd adventure. Since the two new objects we are wanting to create are for the hud, they will only need a position and a draw function. So let's create a new interface for our huds to implement!
// base interface for all hud
interface iHud {
x: number;
y: number;
draw(): void;
}
Now with this nice little interface, let's start with the scoring mechanism. We will need to keep track of how many points the player has racked up, as well as having a function to add points to the total. Let's start off by creating the basic cScore class that implements our new interface.
class cScore implements iHud {
public x: number = 0;
public y: number = 0;
public totalPoints: number = 0;
constructor(x: number, y: number) {
this.x = x;
this.y = y;
}
public addPoints = (p: number): void => {
this.totalPoints += p;
}
public draw = (): void => {
}
}
As you can see, we are going to add a new function that is called addPoints, and it just adds the points it gets to the totalPoints. For the draw function, we are going to just draw out the string "Score: " along with the total points.
public draw = (): void => {
// draw a the score!
ctx.font = "16px Arial";
ctx.fillStyle = "#6495ED";
ctx.fillText("Score: " + this.totalPoints, this.x, this.y);
}
Now that we have a nice scoring mechanism, we should declare a variable to house an instance of it, as well as draw it in the main gameLoop.
var score: cScore = new cScore(8, 20);
function gameLoop()
{
// lets keep the game loop going!
requestAnimationFrame(gameLoop);
// fill to black!
ctx.fillStyle = "black";
ctx.fillRect(0, 0, canvas.width, canvas.height);
var entity: iEntity;
for(var i: number = 0; i < entity_array.length; i++) {
entity = entity_array[i];
entity.update();
entity.draw();
}
score.draw();
}
Now if you compile, you should see "Score: 0" at the top left of the canvas. But what's this? If the ball smashes some bricks the score doesn't go up? That's no fun. We should probably give the bricks some point values, and increase the players score when they are destroyed. First off, lets add a new property to the cBrick class that holds the points the brick is worth. Add it to the constructor, and in the update function, we are going to add points to the score when they ball hits the brick. The cBrick class should now look similar to this.
class cBrick implements iEntity {
public x: number = 0;
public y: number = 0;
public velX: number = 0;
public velY: number = 0;
public width: number = 0;
public height: number = 0;
public color: string = "white";
public active: boolean = true;
public points: number = 0;
constructor(x: number, y: number, width: number, height: number, points: number, color: string = "white")
{
this.x = x;
this.y = y;
this.width = width;
this.height = height;
this.points = points;
this.color = color;
}
public draw = (): void => {
if(this.active) {
// draw the nice little brick!
ctx.save();
ctx.beginPath();
ctx.rect(this.x, this.y, this.width, this.height);
ctx.fillStyle = this.color;
ctx.fill();
ctx.closePath();
}
}
public update = (): void => {
if(this.active) {
if(ball.x + ball.radius > this.x && ball.x - ball.radius < this.x + this.width && ball.y + ball.radius > this.y && ball.y - ball.radius < this.y + this.height) {
ball.velY = -ball.velY;
this.active = false;
score.addPoints(this.points);
}
}
}
}
Since we changed the brick's constructor, we need to change the loadBricks function. For each of the bricks, we can give them a value of 10 for points. If you add the points to when we create the bricks, and then compile and run the game, you should see the points start rolling up when you take out those pesky bricks.
Now to give the player some sort of risk for the rewards. We will be creating a new lives class that is based of the iHud interface that we created above. We want to keep track of lives, and if the lives reach below zero, we want to end the game or reset it. Let's start off by creating the basic live class that implements iHud.
class cLives implements iHud {
public x: number = 0;
public y: number = 0;
public totalLives: number = 0;
constructor(x: number, y: number) {
this.x = x;
this.y = y;
this.totalLives = 3;
}
public draw = (): void => {
}
}
The cLives class will have a counter for totalLives the player has. We will also have functions to add and remove lives. For the draw function, let's create smaller balls to represent a life.
public draw = (): void => {
var xPos: number = this.x;
var yPos: number = this.y;
ctx.save();
for(var l: number = 0; l < this.totalLives; l++) {
ctx.beginPath();
ctx.arc(xPos, yPos, 2, 0, Math.PI*2);
ctx.fillStyle = "white";
ctx.fill();
ctx.closePath();
xPos += 6;
}
}
public addLives = (n: number): void => {
this.totalLives += n;
}
public removeLives = (n: number): void => {
this.totalLives -= n;
if(this.totalLives < 0) {
resetGame();
}
}
Let's delcare a variable to house a new instance of the cLives class and update the gameLoop function to draw our lives.
var lives: cLives = new cLives(8, 310);
function gameLoop()
{
// lets keep the game loop going!
requestAnimationFrame(gameLoop);
// fill to black!
ctx.fillStyle = "black";
ctx.fillRect(0, 0, canvas.width, canvas.height);
var entity: iEntity;
for(var i: number = 0; i < entity_array.length; i++) {
entity = entity_array[i];
entity.update();
entity.draw();
}
score.draw();
lives.draw();
}
Now we need to create the function to reset the game. In the removeLives function of cLives, we are checking to see if totalLives is < 0, and if so, we are going to call a function called resetGame(). Inside this function, we want to reset the lives, score, bricks and ball to their initial settings.
function resetGame() {
entity_array.length = 0;
paddle.reset();
ball.reset();
score.reset();
lives.reset();
entity_array.push(ball);
entity_array.push(paddle);
loadBricks();
}
First off, we are clearing out the entity_array, and then resetting the paddle, ball, score, and lives. Wait a minute, we don't have reset functions for each of those classes. That looks like something that can be added to our interfaces. Let's go ahead and add a reset function for both of our interface classes.
// base interface for our entities that we want to draw on the screen
// and handle in the game.
interface iEntity {
draw(): void;
update(): void;
reset(): void;
x: number;
y: number;
velX: number;
velY: number;
}
// base interface for all hud
interface iHud {
x: number;
y: number;
draw(): void;
reset(): void;
}
So when the ball resets, it will go back to a position and then continue on. We don't want that any more. We want it to hover over the paddle and start moving when the player is ready. To do this, we need to add a new property to the cBall class called status, and then check if it is true or not to move the ball around. While we are in the cBall class, let's fill out the reset function.
class cBall implements iEntity {
public x: number = 0;
public y: number = 0;
public velX: number = 0;
public velY: number = 0;
public radius: number = 0;
public color: string = "white";
public status: boolean = false;
constructor(x: number, y: number, radius: number, color: string = "white")
{
this.x = x;
this.y = y;
this.radius = radius;
this.color = color;
}
public reset = (): void => {
this.velX = 3;
this.velY = 3;
this.y = 290;
this.status = false;
}
public draw = (): void => {
// draw a nice circle!
ctx.save();
ctx.beginPath();
ctx.arc(this.x, this.y, this.radius, 0, Math.PI*2);
ctx.fillStyle = this.color;
ctx.fill();
ctx.closePath();
}
public update = (): void => {
if(this.status) {
// update the balls position
this.x += this.velX;
this.y += this.velY;
// did we hit the left or right bounds?
// if so, lets bounce back the opposite direction.
if(this.x > canvas.width - this.radius || this.x < this.radius) {
this.velX = -this.velX;
}
// did we hit the ceiling?
// if so, let's bounce back down.
if(this.y < this.radius) {
this.velY = -this.velY;
}else if(this.y > paddle.y - this.radius) {
// check to see if the ball is near the height of the paddle
// if so, let's check to see if it is in the same area on the X axis as the paddle,
// and check to see if the ball is still above the paddle.
if(this.y < paddle.y && this.x + this.radius > paddle.x && this.x - this.radius < paddle.x + paddle.width) {
// bounce the ball back up!
// if already going down (if hits paddle at odd angle, and gets stuck, we just want it to go up,
// and not bounce back n forth until out of paddle.)
if(this.velY > 0)
this.velY = -this.velY;
}else if(this.y > canvas.height - this.radius) {
// we lost a life
// reset the ball back to start.
this.reset();
lives.removeLives(1);
}
}
} else {
this.x = paddle.x + paddle.width/2;
}
}
}
That takes care of the ball, now we want to reset the paddle. Resetting the paddle is really simple. We are just going to place it back to the center of the canvas.
public reset = (): void => {
this.x = 215;
}
Bricks will need a reset function, because it implements the iEntity. As of right now, we don't really need to worry about resetting the bricks. So we will just put an empty reset function inside the cBrick class.
public reset = (): void => {
}
In the cLives and cScore classes, we will just zero out the score and reset the lives back to 3. Fairly simple.
public reset = (): void => {
this.totalPoints = 0;
}
public reset = (): void => {
this.totalLives = 3;
}
Now if you compile and run, you should see the ball hover over the paddle. How do we start the game now? Well, we need to add a new check on the keyDownHandler. We need to see if the user is pressing the space bar. If so, check to see if the ball is inactive, and if so, we activate it.
function keyDownHandler(e: KeyboardEvent) {
// right arrow == 39
// left arrow == 37
if(e.keyCode == 39) {
paddle.moveRight = true;
}else if(e.keyCode == 37) {
paddle.moveLeft = true;
}else if(e.keyCode == 32) {
if(ball.status == false) {
ball.status = true;
}
}
}
If you compile and run now, you have something that is getting close to a real game! The score goes up, if you let the ball drop to far you lose a life, and if you run out, it resets the whole game back to square one. Here is what the game.ts should look like so far.
var canvas: HTMLCanvasElement;
var ctx: CanvasRenderingContext2D;
// base interface for our entities that we want to draw on the screen
// and handle in the game.
interface iEntity {
draw(): void;
update(): void;
reset(): void;
x: number;
y: number;
velX: number;
velY: number;
}
// base interface for all hud
interface iHud {
x: number;
y: number;
draw(): void;
reset(): void;
}
// class for the ball entity
class cBall implements iEntity {
public x: number = 0;
public y: number = 0;
public velX: number = 0;
public velY: number = 0;
public radius: number = 0;
public color: string = "white";
public status: boolean = false;
constructor(x: number, y: number, radius: number, color: string = "white")
{
this.x = x;
this.y = y;
this.radius = radius;
this.color = color;
}
public reset = (): void => {
this.velX = 3;
this.velY = 3;
this.y = 290;
this.status = false;
}
public draw = (): void => {
// draw a nice circle!
ctx.save();
ctx.beginPath();
ctx.arc(this.x, this.y, this.radius, 0, Math.PI*2);
ctx.fillStyle = this.color;
ctx.fill();
ctx.closePath();
}
public update = (): void => {
if(this.status) {
// update the balls position
this.x += this.velX;
this.y += this.velY;
// did we hit the left or right bounds?
// if so, lets bounce back the opposite direction.
if(this.x > canvas.width - this.radius || this.x < this.radius) {
this.velX = -this.velX;
}
// did we hit the ceiling?
// if so, let's bounce back down.
if(this.y < this.radius) {
this.velY = -this.velY;
}else if(this.y > paddle.y - this.radius) {
// check to see if the ball is near the height of the paddle
// if so, let's check to see if it is in the same area on the X axis as the paddle,
// and check to see if the ball is still above the paddle.
if(this.y < paddle.y && this.x + this.radius > paddle.x && this.x - this.radius < paddle.x + paddle.width) {
// bounce the ball back up!
// if already going down (if hits paddle at odd angle, and gets stuck, we just want it to go up,
// and not bounce back n forth until out of paddle.)
if(this.velY > 0)
this.velY = -this.velY;
}else if(this.y > canvas.height - this.radius) {
// we lost a life
// reset the ball back to start.
this.reset();
lives.removeLives(1);
}
}
} else {
this.x = paddle.x + paddle.width/2;
}
}
}
// class for the paddle entity (aka, the player)
class cPaddle implements iEntity {
public x: number = 0;
public y: number = 0;
public velX: number = 0;
public velY: number = 0;
public width: number = 0;
public height: number = 0;
public color: string = "white";
public strokeColor: string = "#363636";
public moveRight: boolean = false;
public moveLeft: boolean = false;
constructor(x: number, y: number, width: number, height: number, color: string = "white")
{
this.x = x;
this.y = y;
this.width = width;
this.height = height;
this.color = color;
}
public draw = (): void => {
// draw the nice little paddle!
ctx.save();
ctx.beginPath();
ctx.rect(this.x, this.y, this.width, this.height);
ctx.fillStyle = this.color;
ctx.fill();
ctx.strokeStyle = this.strokeColor;
ctx.lineWidth = 1;
ctx.strokeRect(this.x,this.y,this.width, this.height);
ctx.closePath();
}
public update = (): void => {
// are we moving right or left?
if(this.moveRight) {
this.x += this.velX;
}else if(this.moveLeft) {
this.x -= this.velX;
}
// make sure the user can't go off the X axis bounds.
if(this.x > canvas.width - this.width) {
this.x = canvas.width - this.width;
}
if(this.x < 0) {
this.x = 0;
}
}
public reset = (): void => {
this.x = 215;
}
}
class cBrick implements iEntity {
public x: number = 0;
public y: number = 0;
public velX: number = 0;
public velY: number = 0;
public width: number = 0;
public height: number = 0;
public color: string = "white";
public active: boolean = true;
public points: number = 0;
constructor(x: number, y: number, width: number, height: number, points: number, color: string = "white")
{
this.x = x;
this.y = y;
this.width = width;
this.height = height;
this.points = points;
this.color = color;
}
public draw = (): void => {
if(this.active) {
// draw the nice little brick!
ctx.save();
ctx.beginPath();
ctx.rect(this.x, this.y, this.width, this.height);
ctx.fillStyle = this.color;
ctx.fill();
ctx.closePath();
}
}
public update = (): void => {
if(this.active) {
if(ball.x + ball.radius > this.x && ball.x - ball.radius < this.x + this.width && ball.y + ball.radius > this.y && ball.y - ball.radius < this.y + this.height) {
ball.velY = -ball.velY;
this.active = false;
score.addPoints(this.points);
}
}
}
public reset = (): void => {
}
}
class cScore implements iHud {
public x: number = 0;
public y: number = 0;
public totalPoints: number = 0;
constructor(x: number, y: number) {
this.x = x;
this.y = y;
}
public addPoints = (p: number): void => {
this.totalPoints += p;
}
public draw = (): void => {
// draw a the score!
ctx.font = "16px Arial";
ctx.fillStyle = "#6495ED";
ctx.fillText("Score: " + this.totalPoints, this.x, this.y);
}
public reset = (): void => {
this.totalPoints = 0;
}
}
class cLives implements iHud {
public x: number = 0;
public y: number = 0;
public totalLives: number = 0;
constructor(x: number, y: number) {
this.x = x;
this.y = y;
this.totalLives = 3;
}
public reset = (): void => {
this.totalLives = 3;
}
public draw = (): void => {
var xPos: number = this.x;
var yPos: number = this.y;
ctx.save();
for(var l: number = 0; l < this.totalLives; l++) {
ctx.beginPath();
ctx.arc(xPos, yPos, 2, 0, Math.PI*2);
ctx.fillStyle = "white";
ctx.fill();
ctx.closePath();
xPos += 6;
}
}
public addLives = (n: number): void => {
this.totalLives += n;
}
public removeLives = (n: number): void => {
this.totalLives -= n;
if(this.totalLives < 0) {
resetGame();
}
}
}
var entity_array: Array<iEntity> = new Array<iEntity>();
var ball: cBall = new cBall(240, 290, 5);
var paddle: cPaddle = new cPaddle(215, 300, 50, 10, "gray");
var score: cScore = new cScore(8, 20);
var lives: cLives = new cLives(8, 310);
function gameLoop()
{
// lets keep the game loop going!
requestAnimationFrame(gameLoop);
// fill to black!
ctx.fillStyle = "black";
ctx.fillRect(0, 0, canvas.width, canvas.height);
var entity: iEntity;
for(var i: number = 0; i < entity_array.length; i++) {
entity = entity_array[i];
entity.update();
entity.draw();
}
score.draw();
lives.draw();
}
function keyDownHandler(e: KeyboardEvent) {
// right arrow == 39
// left arrow == 37
if(e.keyCode == 39) {
paddle.moveRight = true;
}else if(e.keyCode == 37) {
paddle.moveLeft = true;
}else if(e.keyCode == 32) {
if(ball.status == false) {
ball.status = true;
}
}
}
function keyUpHandler(e: KeyboardEvent) {
if(e.keyCode == 39) {
paddle.moveRight = false;
}else if(e.keyCode == 37) {
paddle.moveLeft = false;
}
}
function loadBricks() {
var brickMap: string = "RRRRRRRRRRRRRR|BBBBBBBBBBBBBB|GGGGGGGGGGGGGG|YYYYYYYYYYYYYY|WWWWWWWWWWWWWW|YYYYYYYYYYYYYY|GGGGGGGGGGGGGG|BBBBBBBBBBBBBB|RRRRRRRRRRRRRR";
var xpos: number = 35;
var ypos: number = 35;
var pad: number = 5;
var bwidth: number = 25;
var bheight: number = 10;
for(var c: number = 0; c < brickMap.length; c++) {
switch(brickMap[c]) {
case 'R':
{
var b: cBrick = new cBrick(xpos, ypos, bwidth, bheight, 10, "red");
xpos += bwidth + pad;
entity_array.push(b);
}
break;
case 'B':
{
var b: cBrick = new cBrick(xpos, ypos, bwidth, bheight, 10, "blue");
xpos += bwidth + pad;
entity_array.push(b);
}
break;
case 'G':
{
var b: cBrick = new cBrick(xpos, ypos, bwidth, bheight, 10, "green");
xpos += bwidth + pad;
entity_array.push(b);
}
break;
case 'Y':
{
var b: cBrick = new cBrick(xpos, ypos, bwidth, bheight, 10, "yellow");
xpos += bwidth + pad;
entity_array.push(b);
}
break;
case 'W':
{
var b: cBrick = new cBrick(xpos, ypos, bwidth, bheight, 10, "white");
xpos += bwidth + pad;
entity_array.push(b);
}
break;
case '|':
{
xpos = 35;
ypos += bheight + pad;
}
break;
}
}
}
function resetGame() {
entity_array.length = 0;
paddle.reset();
ball.reset();
score.reset();
lives.reset();
entity_array.push(ball);
entity_array.push(paddle);
loadBricks();
}
window.onload = () => {
// grab our canvas, and get a 2d context
canvas = <HTMLCanvasElement>document.getElementById('gameCanvas');
ctx = canvas.getContext("2d");
// let's handle the user input
document.addEventListener('keydown', keyDownHandler);
document.addEventListener('keyup', keyUpHandler);
ball.velX = 3;
ball.velY = 3;
paddle.velX = 5;
entity_array.push(ball);
entity_array.push(paddle);
loadBricks();
// run the game!
gameLoop();
}
On the next tutorial, we will be refactoring how we handle the brick map, and allow the game to have multiple levels as well as adding new types of bricks.