Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / web / HTML5

Html5 Snooker Club

4.95/5 (204 votes)
2 Aug 2017CPOL18 min read 370.4K   6.6K  
Join the HTML 5 revolution by learning how to create a fast game with beautiful graphics and powerful audio experience

screenshot

Table of Contents

Image 2Introduction

There is no doubt HTML5 is behind the great web development revolution we are already witnessing. After so many years of HTML4 reign, a new movement is about to change the web completely, unleashing the modern, richer user experience that so far was exclusive to frameworks running on plug-ins such as Flash and Silverlight.

If you are a very young developer, maybe you are already studying HTML5 from the beginning, so you might not notice the change that much. In any case, I hope this article be useful for you, the same way I hope old dogs like me can learn some new tricks.

Your feedback is important, so I'm looking forward to hearing from you. But what will really make me happy is to know that you right-clicked the page and then thought "Hey, it's not Flash! And it's not Silverlight either!"

Image 3System Requirements

To use HTML5 Snooker Club application provided with this article, all you have to do is install one of the following web browsers: Chrome 12, Internet Explorer 9 or Fire Fox 5.

title

Image 5Game Rules - Overview

game

Maybe you already know what this game is about. It's important to notice this is the "English snooker", and not one of the many variations of it throughout the World. Hm, well... actually, let's call this "simplified English snooker", because not all rules were implemented. Your goal is to score more points than the other player, by potting the object balls in the correct order. At your turn, you are conceded a shot: with the tip of the cue, you must strike the cue aiming to pot one of the red balls and then score one point. If you pot at least one red, it keeps in the pocket, and you are conceded another shot - but now you must aim to pot one of the "colours" (that is, all the non-red balls). If you succeed, you score the value of the colour(s) potted. Then the colour returns to the table and you must try to pot another red. This goes on and on, until you fail to pot the "ball on" (that is, the ball you were supposed to pot at any time), in which event you should leave and your opponent is conceded the next turn. The game continues until all the red balls are potted. When only the 6 colours are left in the table, your goal is to pot them in a predefined order: yellow (2 points), green (3 points), brown (4 points), blue (5 points), pink (6 balls) and black (7 points). If a ball different from the predefined one is potted, then it comes back to the table in its original position. Otherwise, it keeps inside the pocket. When the remaining (black ball) is potted, the game ends, and the player with greatest score wins.

Image 7Fouls

If a hit is a foul, then the other player gets penalty points:

  • 4 points if the white ball is potted
  • If the white hits the wrong ball first, then the value of this ball
  • If the wrong ball is potted first, then the value of this ball
  • Penalty points have a minimal value of 4

The code snippet belows show how we manage to calculate the fouls:

JavaScript
	var strokenBallsCount = 0;
console.log('strokenBalls.length: ' + strokenBalls.length);
	for (var i = 0; i < strokenBalls.length; i++) {
		var ball = strokenBalls[i];
		// causing the cue ball to first hit a ball other than the ball on
		if (strokenBallsCount == 0) {
		    if (ball.Points != teams[playingTeamID - 1].BallOn.Points) {
		        if (ball.Points == 1 || teams[playingTeamID - 1].BallOn.Points == 1 || 
                fallenRedCount == redCount) {
		            if (teams[playingTeamID - 1].BallOn.Points < 4) {
		                teams[playingTeamID - 1].FoulList[teams[playingTeamID - 1]
                        .FoulList.length] = 4;
		                $('#gameEvents').append('<br/>Foul 4 points :  Expected ' +
                         teams[playingTeamID - 1].BallOn.Points + ', but hit ' + ball.Points);
		            }
		            else {
		                teams[playingTeamID - 1].FoulList[teams[playingTeamID - 1]
                        .FoulList.length] = teams[playingTeamID - 1].BallOn.Points;
		                $('#gameEvents').append('<br/>Foul ' + teams[playingTeamID - 1]
                        .BallOn.Points + ' points :  Expected ' + teams[playingTeamID - 1]
                        .BallOn.Points + ', but hit ' + ball.Points);
		            }
		            break;
		        }
		    }
		}

		strokenBallsCount++;
	}

	//Foul: causing the cue ball to miss all object balls
	if (strokenBallsCount == 0) {
		teams[playingTeamID - 1].FoulList[teams[playingTeamID - 1].FoulList.length] = 4;
		$('#gameEvents').append('<br/>Foul 4 points :  causing the cue ball 
        to miss all object balls');
	}

	for (var i = 0; i < pottedBalls.length; i++) {
		var ball = pottedBalls[i];
		// causing the cue ball to enter a pocket
		if (ball.Points == 0) {
		    teams[playingTeamID - 1].FoulList[teams[playingTeamID - 1].FoulList.length] = 4;
		    $('#gameEvents').append('<br/>Foul 4 points :  causing the cue ball
             to enter a pocket');
		}
		else {
		    // causing a ball different than the target ball to enter a pocket
		    if (ball.Points != teams[playingTeamID - 1].BallOn.Points) {
		        if (ball.Points == 1 || teams[playingTeamID - 1].BallOn.Points == 1
                 || fallenRedCount == redCount) {
		            if (teams[playingTeamID - 1].BallOn.Points < 4) {
		                teams[playingTeamID - 1].FoulList[teams[playingTeamID - 1]
                        .FoulList.length] = 4;
		                $('#gameEvents').append('<br/>Foul 4 points : '
                         + ball.Points + ' was potted, while ' + teams[playingTeamID - 1]
                         .BallOn.Points + ' was expected');
		                $('#gameEvents').append('<br/>ball.Points: ' + ball.Points);
		                $('#gameEvents').append('<br/>teams[playingTeamID - 1]
                        .BallOn.Points: ' + teams[playingTeamID - 1].BallOn.Points);
		                $('#gameEvents').append('<br/>fallenRedCount: ' + fallenRedCount);
		                $('#gameEvents').append('<br/>redCount: ' + redCount);
		            }
		            else {
		                teams[playingTeamID - 1].FoulList[teams[playingTeamID - 1]
                        .FoulList.length] = teams[playingTeamID - 1].BallOn.Points;
		                $('#gameEvents').append('<br/>Foul ' + teams[playingTeamID - 1]
                        .BallOn.Points + ' points : ' + ball.Points + ' was potted, while '
                         + teams[playingTeamID - 1].BallOn.Points + ' was expected');
		            }
		        }
		    }
		}
	}

Image 8Scoring

The goal of snooker is to pocket the balls legally according to the rules and to score a greater number of points than the opponent. Point values for object balls: red (1), yellow (2), green (3), brown (4), blue (5), pink (6), black (7).

JavaScript
if (teams[playingTeamID - 1].FoulList.length == 0) {
    for (var i = 0; i < pottedBalls.length; i++) {
        var ball = pottedBalls[i];
        //legally potting reds or colors
        wonPoints += ball.Points;
        $('#gameEvents').append('<br/> Potted +' + ball.Points + ' points.');
    }
}
else {
    teams[playingTeamID - 1].FoulList.sort();
    lostPoints = teams[playingTeamID - 1].FoulList
                 [teams[playingTeamID - 1].FoulList.length - 1];
    $('#gameEvents').append('<br/> Lost ' + lostPoints + ' points.');
}
teams[playingTeamID - 1].Points += wonPoints;
teams[awaitingTeamID - 1].Points += lostPoints;

Image 9Animating Players' Pictures

picture animation

The game has two players, and each one has a picture and a name below it. The players' names are simply "player 1" and "player 2" (although it might be a nice idea to create a user input for it), and each one has a picture of a very smart dog playing snooker. When a player is conceded a turn, the application "blinks" the picture of him/hers, and then switches off the light for the picture of the opponent.

This is done by changing the CSS-3 property opacity of the img element containing the picture: we use jQuery animation to change the opacity to 0.0 and then back to 1.0.

JavaScript
function animateCurrentPlayerImage() {
    var otherPlayerImageId = 0;
    if (playingTeamID == 1)
        otherPlayerImageId = 'player2Image';
    else
        otherPlayerImageId = 'player1Image';
    var playerImageId = 'player' + playingTeamID + 'Image';
    $('#' + playerImageId).animate({
        opacity: 1.0
    }, 500, function () {
        $('#' + playerImageId).animate({
            opacity: 0.0
        }, 500, function () {
            $('#' + playerImageId).animate({
                opacity: 1.0
            }, 500, function () {
            });
        });
    });

    $('#' + otherPlayerImageId).animate({
        opacity: 0.25
    }, 1500, function () {
    });
}

Image 11The Strength Bar

strength bar

Good snooker players know how much strength to apply in each shot. Different techniques require different types of stroke: direct, indirect, using the borders (banks), and so on. The combination of different directions and different strength levels can create millions of possible paths (Ok, I said "millions", but that's just a guesswork). Luckily, HTML5 Snooker Club comes with a nice strength level bar, which allows players to calibrate their cues before each shot.

For this feature, we are using the new HTML5 meter element, which is intended to define a measurement. It works as a gauge. It is advised that you use it the meter tag only for measurements with a known minimum and maximum value. In our case, the value must lie between zero (in case you have mental powers, like Professor X) and 100 (if you are as smooth as Hulk). Since this meter element is not (yet) supported in IE9, I used a div with background images instead. The effect is the same.

JavaScript
#strengthBar { position: absolute; margin:375px 0 0 139px;
    width: 150px; color: lime; background-color: orange;
    z-index: 5;}

When you click the strength bar, you are in fact selecting a new strength. At first, your shot will appear unskilled, but as in real game, it takes time and training for you to become perfect.

JavaScript
$('#strengthBar').click(function (e) {
    var left = $('#strengthBar').css('margin-left').replace('px', '');
    var x = e.pageX - left;
    strength = (x / 150.0);
    $('#strengthBar').val(strength * 100);
});

Image 13Showing the Ball On

ball on

Inside the picture box of the current player, you will notice a small ball. This is the "ball on", or the ball that the player is supposed to pot at that specific time. If the ball on is missed, the player loses 4 points. If the player strikes first a ball other than the ball on, he/she loses 4 points too.

The ball on is made up by a set of drawings (arcs and lines) directly over a canvas element which covers each player picture (we'll deal with ball rendering soon in this article). So, when you see that ball over the picture, it looks like a standard img element over a div, but there's no image element for the balls. Also, we could not draw arcs and lines directly over a div, and that's why we need a canvas over the player's image.

HTML
<canvas id="player1BallOn" class="player1BallOn">
</canvas>
<canvas id="player2BallOn" class="player2BallOn">
</canvas>
JavaScript
var player1BallOnContext = player1BallOnCanvas.getContext('2d');
var player2BallOnContext = player2BallOnCanvas.getContext('2d');
.
.
.
function renderBallOn() {
    player1BallOnContext.clearRect(0, 0, 500, 500);
    player2BallOnContext.clearRect(0, 0, 500, 500);
    if (playingTeamID == 1) {
        if (teams[0].BallOn != null)
            drawBall(player1BallOnContext, teams[0].BallOn, new Vector2D(30, 120), 20);
    }
    else {
        if (teams[1].BallOn != null)
            drawBall(player2BallOnContext, teams[1].BallOn, new Vector2D(30, 120), 20);
        player1BallOnContext.clearRect(0, 0, 133, 70);
    }
}

Image 15Rotating the Roof Fan

The roof fan over the game table is there just for fun (well, the entire article is about fun, but this is another kind of fun...). Why is it there? Well, since the game name is Html5 Snooker Club, the idea is to give it an atmosphere of a Club. Also, it's a means to show how to implement CSS 3 rotation.

The implementation is quite simple: first, we have an PNG image for the roof fan. Not the image of the roof fan itself, but rather the shadow of it. But by showing the shadow cast over the game table instead of the actual fan, we do this on purpose, to create an effect where the fan is perceived to be above us.

HTML
#roofFan { position:absolute; left: 600px; top: -100px; width: 500px; height: 500px;
    border: 2px solid transparent; background-image: url('/Content/Images/roofFan.png');
    background-size: 100%; opacity: 0.3; z-index: 2;}
.
.
.
<div id="roofFan"> </div>

In order to obtain a more realistic atmosphere, I picked up a roof fan drawing and used Paint.Net software to apply the blur/smoothing effect. Now you can't see the edges of the fans anymore. I think it's a simple solution and gave it a very cool result.

roof fan

Apart from the trick with the imaging program, the element used in the roof shadow is an ordinary div element with an image as its background. There's nothing special about it. Now that we got a roof fan, we want it to start spinning. And here is where CSS 3 makes all the difference. CSS 3 now comes with the rotate transform attribute, which we will use to animate our fan.

Besides being a powerful feature, rotation is quite simple to implement with CSS 3. All you need to do is to apply the rotation arc (in degrees) to the transform property of the element. In this case, we use three different transform properties (for different browsers): -moz-transform, -webkit-transform and msTransform. It would be cool if we no longer had to worry about which browser our users are playing with, but that's another story...

The next image shows a snapshot of the roof fan div rotating at the position of 60 degrees:

roof fan

JavaScript
var srotate = "rotate(" + renderStep * 10 + "deg)";
$("#roofFan").css({ "-moz-transform": srotate,
"-webkit-transform": srotate, msTransform: srotate });

Image 18Rotating the Cue

rotating the cue

The cue animation is another feature which is not really necessary for the game, but certainly adds much to the fun. Once you start hovering the mouse over the game table, you will notice that the cue actually "follows" the mouse cursor. That is, the cue keeps aiming all the time to the mouse cursor, exactly as would happen in a real game. Since the player has nothing but his/her own eyes to take aim, the cue direction can be of great help.

The cue is made up of a single PNG image. The image itself is not rendered as an img element, nor as a background image of some other element. Instead, the image is rendered directly on a dedicated canvas. We could get the same result by animating a div and applying CSS 3 transforms, but I think this will be good to show how to render images over canvas.

First, the canvas element is stretched over almost all the page width. It's important to notice that this particular canvas has a greater z-index, so that it can appear on the top of the canvas dedicated to the balls. Any time the mouse hovers over the table, the target point is updated, and the cue image is rendered with 2 transforms: first, by translating the cue to the cue ball position, and second, by rotating the cue around the cue ball, in such a way that the mouse pointer, the cue ball center and the cue become perfectly aligned.

JavaScript
#cue { position:absolute; }
.
.
.
if (drawingtopCanvas.getContext) {
	var cueContext = drawingtopCanvas.getContext('2d');
}
.
.
.
var cueCenter = [15, -4];
var cue = new Image;
cue.src = '<%: Url.Content("../Content/Images/cue.PNG") %>';

var shadowCue = new Image;
shadowCue.src = '<%: Url.Content("../Content/Images/shadowCue.PNG") %>';
cueContext.clearRect(0, 0, topCanvasWidth, topCanvasHeight);

	if (isReady) {
		cueContext.save();
		cueContext.translate(cueBall.position.x + 351, cueBall.position.y + 145);
		cueContext.rotate(shadowRotationAngle - Math.PI / 2);
		cueContext.drawImage(shadowCue, cueCenter[0] + cueDistance, cueCenter[1]);
		cueContext.restore();
		cueContext.save();
		cueContext.translate(cueBall.position.x + 351, cueBall.position.y + 140);
		cueContext.rotate(angle - Math.PI / 2);
		cueContext.drawImage(cue, cueCenter[0] + cueDistance, cueCenter[1]);
		cueContext.restore();
	}

Also, the cue becomes much more real when we render the cue shadow first. But since the shadow lies between the game and the cue itself, it's important to remember to render the shadows before rendering the cue. The cue shadow rotation angle is intentionally different from the cue angle. We do this in order to give the cue a 3-D effect. The final result is a cool, realistic, 3-D like game rendering.

The cue shadow is just an image with exactly the same size/shape of the cue, but with a smoothened black background.

Image 20Pushing / Pulling the Cue

pushing / pulling the cue

pushing / pulling the cue

pushing / pulling the cue

The cue animation also imitates a common human trait: Have you ever seen snooker players pushing/pulling their cues when taking aim? We accomplish this effect in Html5 Snooker Club by varying the distance between the cue ball and the cue, in a timely fashion. The cue is pulled back until it reaches a limit. Then it is pushed forward until the other limit. And this goes on and on, as long as the player doesn't move the mouse cursor.

JavaScript
var cueDistance = 0;
var cuePulling = true;
.
.
.
		function render() {
            .
            .
            .

		    if (cuePulling) {
		        if (lastMouseX == mouseX ||
                lastMouseY == mouseY) {
		            cueDistance += 1;
		        }
		        else {
		            cuePulling = false;
		            getMouseXY();
		        }
		    }
		    else {

		        cueDistance -= 1;
		    }

		    if (cueDistance > 40) {
		        cueDistance = 40;
		        cuePulling = false;
		    }
		    else if (cueDistance < 0) {
		        cueDistance = 0;
		        cuePulling = true;
		    }
            .
            .
            .

Image 24Showing the Target Path

balls

While the player moves the cursor mouse around, we draw a dashed line from the center of the cue ball to the mouse cursor. This can be particularly handy for the player trying to in aim at a long distance.

This target line is drawn only when the game is awaiting for the player's shot:

JavaScript
if (!cueBall.pocketIndex) {
    context.strokeStyle = '#888';
    context.lineWidth = 4;
    context.lineCap = 'round';
    context.beginPath();

    //here we draw the line
    context.dashedLine(cueBall.position.x, cueBall.position.y, targetX, targetY);

    context.closePath();
    context.stroke();
}

It is important to notice that there is no built-in function to draw dashed lines in HTML5 canvas. Fortunately, the project comes with a function that was posted by the user phrogz at the StackOverflow website:

JavaScript
//function kindly provided by phrogz at:
//http://stackoverflow.com/questions/4576724/dotted-stroke-in-canvas
var CP = window.CanvasRenderingContext2D && CanvasRenderingContext2D.prototype;
if (CP && CP.lineTo) {
    CP.dashedLine = function (x, y, x2, y2, dashArray) {
        if (!dashArray) dashArray = [10, 5];
        var dashCount = dashArray.length;
        this.moveTo(x, y);
        var dx = (x2 - x), dy = (y2 - y);
        var slope = dy / dx;
        var distRemaining = Math.sqrt(dx * dx + dy * dy);
        var dashIndex = 0, draw = true;
        while (distRemaining >= 0.1) {
            var dashLength = dashArray[dashIndex++ % dashCount];
            if (dashLength > distRemaining) dashLength = distRemaining;
            var xStep = Math.sqrt(dashLength * dashLength / (1 + slope * slope));

            var signal = (x2 > x ? 1 : -1);

            x += xStep * signal;
            y += slope * xStep * signal;
            this[draw ? 'lineTo' : 'moveTo'](x, y);
            distRemaining -= dashLength;
            draw = !draw;
        }
    }
}

Image 26Showing the Tracing Path

balls

As soon as the player shoots, the cue leaves behind a light green trace, which indicates the path made by its previous positions.

Creating this path is a bit more complicated than creating the target path we've seen before. First, we have to instantiate a Queue object. The Queue prototype included in this project was created by Stephen Morley.

JavaScript
var tracingQueue = new Queue();

As long as the balls are rendered, we store the cue ball position in the queue:

JavaScript
if (renderStep % 2 == 0) {
    draw();
    enqueuePosition(new Vector2D(cueBall.position.x, cueBall.position.y));
}

The enqueuePosition function ensures that we don't store more than 20 positions. This is so because we only want to show the most recent portion of the cue ball path.

JavaScript
function enqueuePosition(position) {
    tracingQueue.enqueue(position);
    var len = tracingQueue.getLength();

    if (len > 20) {
        tracingQueue.dequeue();
    }
}

Next, we iterate over the queue array in order to create a dashed path:

JavaScript
//drawing the tracing line
var lastPosX = cueBall.position.x;
var lastPosY = cueBall.position.y;

var arr = tracingQueue.getArray();

if (!cueBall.pocketIndex) {
    context.strokeStyle = '#363';
    context.lineWidth = 8;
    context.lineCap = 'round';

    context.beginPath();
    var i = arr.length;
    while (--i > -1) {
        var posX = arr[i].x;
        var posY = arr[i].y;
        context.dashedLine(lastPosX, lastPosY, posX, posY, [10,200,10,20]);
        lastPosX = posX;
        lastPosY = posY;
    }

    context.closePath();
    context.stroke();
}

Image 28Drawing the Balls

balls

The balls and their shadows are rendered on a specific canvas (right below the cue canvas) which lies over the table border image.

Before rendering the balls, we must render the ball shadows. Again, this is done to keep the pseudo 3-D environment we are trying to emulate. Each ball must have a shadow, and we position each shadow according to its corresponding ball, with just a small difference. This difference shows that the balls are casting shadows in a given direction, and also reveals where the source light is located.

balls

Each ball is drawn by a common function, that takes two parameters:

  1. the canvas context and
  2. the ball object

The function then creates complete arcs and fill them with gradients, using the colors assigned to the balls.

Each ball object has three colors: the light color, the medium color and the dark color. These colors are used to create the gradient, so (once again) the 3-D effect is applied here, too.

As an alternative to this technique, we could use images over canvas, or images over an ordinary div. But in this case, we wouldn't have the same scalable, smooth rendering as we do with drawing over canvas.

JavaScript
function drawBall(context, ball, newPosition, newSize) {
    var position = ball.position;
    var size = ball.size;

    if (newPosition != null)
        position = newPosition;

    if (newSize != null)
        size = newSize;

    //main circle
    context.beginPath();
    context.fillStyle = ball.color;
    context.arc(position.x, position.y, size, 0, Math.PI * 2, true);

    var gradient = context.createRadialGradient(
        position.x - size / 2, position.y - size / 2, 0, position.x,
        position.y, size );

    //bright spot
    gradient.addColorStop(0, ball.color);
    gradient.addColorStop(1, ball.darkColor);
    context.fillStyle = gradient;
    context.fill();
    context.closePath();

    context.beginPath();
    context.arc(position.x, position.y, size * 0.85, (Math.PI / 180) * 270,
    (Math.PI / 180) * 200, true);
    context.lineTo(ball.x, ball.y);
    var gradient = context.createRadialGradient(
        position.x - size * .5, position.y - size * .5,
        0, position.x, position.y, size);

    gradient.addColorStop(0, ball.lightColor);
    gradient.addColorStop(0.5, 'transparent');
    context.fillStyle = gradient;
    context.fill();
}

function drawBallShadow(context, ball) {
    //main circle
    context.beginPath();
    context.arc(ball.position.x + ball.size * .25, ball.position.y + ball.size * .25,
     ball.size * 2, 0, Math.PI * 2, true);

    try {
        var gradient = context.createRadialGradient(
            ball.position.x + ball.size * .25, ball.position.y + ball.size * .25,
            0, ball.position.x + ball.size * .25, ball.position.y + ball.size * .25,
            ball.size * 1.5 );
    }
    catch (err) {
        alert(err);
        alert(ball.position.x + ',' + ball.position.y);
    }

    gradient.addColorStop(0, '#000000');
    gradient.addColorStop(1, 'transparent');
    context.fillStyle = gradient;
    context.fill();
    context.closePath();
}

Image 31Detecting Ball-To-Ball Collisions

ball-to-ball collision

The balls are rendered on the canvas in a fast and continuous way: First, we clear the canvas. Then we draw the shadows. Then we draw the balls. Then we update the balls positions. And start over and over again. In the meantime, we need to check whether a ball is hitting another ball. We do this by detecting ball-to-ball collision.

Time to remember what we learned (or not) in the school. Trigonometry tells us to check if the distance between 2 balls is below 2 times the radius. In this case, then yes, we have a collision, and yes, we have to resolve it.

Usually, we would get the distance between the centers of the 2 balls by calculating the hypotenuse of the triangle created by the differences of the x and y values for each balls (the deltas). Then we would compare this distance to the radius x 2. But fortunately, there is a simpler and faster to do it. We just need to compare: (A) the square of (2 x radius) to (B)the sum of the squares of the deltas. If (A) is less than (B), then we have a collision.

JavaScript
function isColliding(ball1, ball2) {
    if (ball1.pocketIndex == null && ball2.pocketIndex == null) {
        var xd = (ball1.position.x - ball2.position.x);
        var yd = (ball1.position.y - ball2.position.y);

        var sumRadius = ball1.size + ball2.size;
        var sqrRadius = sumRadius * sumRadius;

        var distSqr = (xd * xd) + (yd * yd);

        if (Math.round(distSqr) <= Math.round(sqrRadius)) {

            if (ball1.Points == 0) {
                strokenBalls[strokenBalls.length] = ball2;
            }
            else if (ball2.Points == 0) {
                strokenBalls[strokenBalls.length] = ball1;
            }
            return true;
        }
    }
    return false;
}

Image 33Resolving Ball-To-Ball Collisions

Resolving Ball-To-Ball Collisions

Image from Wikipedia

I consider ball-to-ball collision resolution to be the core of this project: if it doesn't work, everything else in the game is useless.

Ball-to-ball collision is by far the harder part, in my opinion. First, we compare each 2 balls combination (ball 1 and ball 2). Then we "resolve intersection", that is, we move them to the exact positions they were at the moment of the collision. We do this by a few vectorial calculations. The next step is to calculate the final collision impulse, and finally we "change the momentum" of both balls, that is, we use the resulting impulse vector to add to/subtract from their velocity vectors. When the collision is resolved, the balls are no longer colliding, but their positions and velocities have been changed as the result of the collision.

JavaScript
function resolveCollision(ball1, ball2) {
    // get the mtd (minimum translation distance)
    var delta = ball1.position.subtract(ball2.position);
    var r = ball1.size + ball2.size;
    var dist2 = delta.dot(delta);

    var d = delta.length();

    var mtd = delta.multiply(((ball1.size + ball2.size + 0.1) - d) / d);

    // resolve intersection --
    // inverse mass quantities

    var mass = 0.5;

    var im1 = 1.0 / mass;
    var im2 = 1.0 / mass;

    // push-pull them apart based off their mass
    if (!ball1.isFixed)
        ball1.position = ball1.position.add((mtd.multiply(im1 / (im1 + im2))));
    if (!ball2.isFixed)
        ball2.position = ball2.position.subtract(mtd.multiply(im2 / (im1 + im2)));

    // impact speed
    var v = ball1.velocity.subtract(ball2.velocity);
    var vn = v.dot(mtd.normalize());

    // sphere intersecting but moving away from each other already
    //                if (vn > 0)
    //                    return;

    // collision impulse
    var i = (-(0.0 + 0.08) * vn) / (im1 + im2);
    var impulse = mtd.multiply(0.5);

    var totalImpulse = Math.abs(impulse.x) + Math.abs(impulse.y);

        //Do some collision audio effects here...

    // change in momentum
    if (!ball1.isFixed)
        ball1.velocity = ball1.velocity.add(impulse.multiply(im1));
    if (!ball2.isFixed)
        ball2.velocity = ball2.velocity.subtract(impulse.multiply(im2));
}

Image 35Detecting Ball-To-Corner Collisions

At first sight, it may seem that detecting collisions between the balls and the corners are a complicated task. But fortunately, there is a simple and effective way to do this: since corners are round segments, we can imagine them as fixed balls. If we know how to determine the sizes and positions of this fixed balls correctly, we can handle collisions the same way as we handle ball-to-ball detection. In fact, we apply exactly the same function to do both detection categories. The only difference lies on the fact that corners never move.

The image below reveals where the imaginary corner balls would be, if they were real:

corner balls

Image 37Resolving Ball-To-Corner Collisions

As mentioned before, the only difference between ball-to-ball and ball-to-corner collision resolution is that in the second case, we must ensure that we don't change the positions (nor the velocity vectors) of the "imaginary balls" that represent the corners.

JavaScript
function resolveCollision(ball1, ball2) {
    .
    .
    .
    // push-pull them apart based off their mass
    if (!ball1.isFixed)
        ball1.position = ball1.position.add((mtd.multiply(im1 / (im1 + im2))));
    if (!ball2.isFixed)
        ball2.position = ball2.position.subtract(mtd.multiply(im2 / (im1 + im2)));

    .
    .
    .

    // change in momentum
    if (!ball1.isFixed)
        ball1.velocity = ball1.velocity.add(impulse.multiply(im1));
    if (!ball2.isFixed)
        ball2.velocity = ball2.velocity.subtract(impulse.multiply(im2));
}

Image 38Detecting Ball-To-Rectangle Collisions

We use ball-to-rectangle collisions detection to know whether the balls have reached the left, right, upper and lower limits of our table. This kind of collision detection is pretty straightforward; for this, each ball has 4 spots to check: we can calculate this points by adding and subtracting to the ball's center point x and y coordinates. Then we compare them to see if they lie between the limits of the rectangle defined by our game table.

Image 39Resolving Ball-To-Rectangle Collisions

Resolving Ball-To-Rectangle Collisions

Image from Wikipedia

Handling ball-to-rectangle collision is much easier than ball-to-ball collisions. We must find the closest point on the rectangle to the ball centre. If that point is inside the ball radius, we have a collision. In this case, we reflect the ball direction (that is, the velocity vector) along the normalised vector (ball.center - point).

Image 41Playing Audio

audio

No game would be complete without audio. Even Pong had audio! Different platforms have different ways of dealing with audio files. Fortunately, HTML5 provides us with the nice audio tag, that simplifies a lot the job of defining the audio files, loading, setting volume and playing.

Usually, HTML5 demos show the audio element in its standard way, that is, displaying the playback controls (play/pause/stop buttons, progress bar and time indicator). Html5 Snooker Club takes a different approach, by hiding these controls from the user. This makes sense, because the audio here is not controlled directly by our user. Instead, the audio is played only when some game event (such as shooting, striking or potting) occurs.

The page contains 8 audio tags, being 6 of them for playing ball-hitting sounds (varying by intensity), one for shot sound and another for the ball falling into the pocket. These sounds can be played at the same time, so we don't need to handle concurrency.

When the player shoots the cue ball, we play the "shot" sound with a volume that depends on the selected strength:

JavaScript
$('#topCanvas').click(function (e) {
.
.
.
audioShot.volume = strength / 100.0;
audioShot.play();
.
.
.
});

When a ball hits another one, we calculate the intensity of the hit and select the property volume/audio tag for that sound effect:

JavaScript
function resolveCollision(ball1, ball2) {
    .
    .
    .
    var totalImpulse = Math.abs(impulse.x) + Math.abs(impulse.y);

    var audioHit;
    var volume = 1.0;

    if (totalImpulse > 5) {
        audioHit = audioHit06;
        volume = totalImpulse / 60.0;
    }
    else if (totalImpulse > 4) {
        audioHit = audioHit05;
        volume = totalImpulse / 12.0;
    }
    else if (totalImpulse > 3) {
        audioHit = audioHit04;
        volume = totalImpulse / 8.0;
    }
    else if (totalImpulse > 2) {
        audioHit = audioHit03;
        volume = totalImpulse / 5.0;
    }
    else {
        audioHit = audioHit02;
        volume = totalImpulse / 5.0;
    }

    if (audioHit != null) {
        if (volume > 1)
            volume = 1.0;

        //audioHit.volume = volume;
        audioHit.play();
    }
    .
    .
    .
}

Finally, when a ball falls into a pocket, we play the "fall.mp3" sound:

JavaScript
function pocketCheck() {
    for (var ballIndex = 0; ballIndex < balls.length; ballIndex++) {

        var ball = balls[ballIndex];

        for (var pocketIndex = 0; pocketIndex < pockets.length; pocketIndex++) {
            .
            . some code here...
            .
            if (Math.round(distSqr) < Math.round(sqrRadius)) {
                if (ball.pocketIndex == null) {
                    ball.velocity = new Vector2D(0, 0);

                    ball.pocketIndex = pocketIndex;

                    pottedBalls[pottedBalls.length] = ball;

                    if (audioFall != null)
                        audioFall.play();
                }
            }
        }
    }
}

Image 43Using Local Storage to Save Game State

Sometimes called web storage or DOM storage, local storage is the name of the mechanism introduced by HTML5 used for persisting data locally. Local storage is natively supported by the browsers listed at the top of this article, so no additional JavaScript framework is needed.

We will use local storage in this article as a means to persist the game state between user sessions. Simply put, we will allow the user to start playing for some time, close the browser, open it in another day, resuming the same game state, play a bit more, and so on.

When the game starts, we always check whether there is some data saved on the local storage, and retrieve it when needed:

JavaScript
jQuery(document).ready(function () {
    ...
    retrieveGameState();
    ...

On the other side, we always save the game state after processing the points of each shot:

JavaScript
function render() {
    ...
    processFallenBalls();
    saveGameState();
    ...
}

Local storage is implemented as a dictionary of strings. This simple structure is ready to use (we don't need to initialize the local storage) and accepts our string and numeric entries. We just need the setItem to start storing values in the local storage. This is how we store the saved date and time, the ball positions, the players data and the Ids for the playing player and awaiting player:

JavaScript
function saveGameState() {
    //we use this to check whether the browser supports local storage
    if (Modernizr.localstorage) {
        localStorage["lastGameSaveDate"] = new Date();
        lastGameSaveDate = localStorage["lastGameSaveDate"];
        localStorage.setItem("balls", $.toJSON(balls));
        localStorage.setItem("teams", $.toJSON(teams));
        localStorage.setItem("playingTeamID", playingTeamID);
        localStorage.setItem("awaitingTeamID", awaitingTeamID);
    }
}

I think the code above is self explaining, except for this part:

JavaScript
localStorage.setItem("balls", $.toJSON(balls));
localStorage.setItem("teams", $.toJSON(teams));

So far, local storage doesn't seem to work with objects, so we must "stringify" them. The above lines shows that we used the useful toJSON jQuery method, which converts complex structures (such as arrays of ball objects) into JSON (Javascript Simple Object Notation), a string-based serialized structure. After each shot, the JSON value representing the balls array will look like:

JavaScript
[{"isFixed":false,"color":"#ff0000","lightColor":"#ffffff","darkColor":"#400000","bounce":0.5,
"velocity":{"x":0,"y":0},"size":10,"position":{"x":190,"y":150},"pocketIndex":null,"points":1,
"initPosition":{"x":190,"y":150},"id":0},
 {"isFixed":false,"color":"#ff0000","lightColor":"#ffffff",
"darkColor":"#400000","bounce":0.5,"velocity":{"x":0,"y":0},
"size":10,"position":{"x":172,"y":138},
"pocketIndex":null,"points":1,"initPosition":{"x":172,"y":138},"id":1},........

Once we have the serialized structures inside local storage, we can retrieve it in a similar way. This time, we use the getItem for retrieving values from the storage.

The big problem now, is that we once we get the deserialized objects. Since Ball has been prototyped with methods, the objects extracted from local storage simply don't work, because they are plain objects that lack that prototyping. So, we now transfer their data to fully prototyped objects that will later be used in the game:

JavaScript
function retrieveGameState() {
    // We use this to check whether the browser supports local storage.
    if (Modernizr.localstorage) {

        lastGameSaveDate = localStorage["lastGameSaveDate"];
        if (lastGameSaveDate) {
            var jsonBalls = $.evalJSON(localStorage.getItem("balls"));
            balls = [];

            var ballsOnTable = 0;

            for (var i = 0; i < jsonBalls.length; i++) {
                var jsonBall = jsonBalls[i];
                var ball = {};
                ball.position = new Vector2D(jsonBall.position.x, jsonBall.position.y);
                ball.velocity = new Vector2D(0, 0);

                ball.isFixed = jsonBall.isFixed;
                ball.color = jsonBall.color;
                ball.lightColor = jsonBall.lightColor;
                ball.darkColor = jsonBall.darkColor;
                ball.bounce = jsonBall.bounce;
                ball.size = jsonBall.size;
                ball.pocketIndex = jsonBall.pocketIndex;
                ball.points = jsonBall.points;
                ball.initPosition = jsonBall.initPosition;
                ball.id = jsonBall.id;
                balls[balls.length] = ball;

                if (ball.points > 0 && ball.pocketIndex == null) {
                    ballsOnTable++;
                }
            }

            // If there are no more balls on the table, clear local storage
            // and reload the game
            if (ballsOnTable == 0) {
                localStorage.clear();
                window.location.reload();
            }

            var jsonTeams = $.evalJSON(localStorage.getItem("teams"));
            teams = jsonTeams;
            if (jsonTeams[0].BallOn)
                teams[0].BallOn = balls[jsonTeams[0].BallOn.id];
            if (jsonTeams[1].BallOn)
                teams[1].BallOn = balls[jsonTeams[1].BallOn.id];

            playingTeamID = localStorage.getItem("playingTeamID");
            awaitingTeamID = localStorage.getItem("awaitingTeamID");
        }
    }

Image 44Final Considerations

There is little doubt that HTML5 will transform the web completely. This revolution is already taking place, and I hope you take this article as an invitation to join this revolution. Here, we have seen how canvas, CSS 3 transformations, audio and local storage in HTML5 works. Although the snooker game engine might look complex, the HTML5 techniques I used on it are quite simple. And I admit I wasn't expecting such good results.

If you like, dislike, have opinions on the article, please leave a comment! I'm willing to modify the article based on your experience, so please let me know what you are thinking.

Image 45History

  • 28th June, 2011: Initial version
  • 30th June, 2011: Target path and tracing path
  • 2nd July, 2011: Live demo
  • 7th July, 2011: Local storage features added

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)