Table of Contents
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!"
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.
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.
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:
var strokenBallsCount = 0;
console.log('strokenBalls.length: ' + strokenBalls.length);
for (var i = 0; i < strokenBalls.length; i++) {
var ball = strokenBalls[i];
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++;
}
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];
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 {
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');
}
}
}
}
}
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).
if (teams[playingTeamID - 1].FoulList.length == 0) {
for (var i = 0; i < pottedBalls.length; i++) {
var ball = pottedBalls[i];
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;
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.
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 () {
});
}
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.
#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.
$('#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);
});
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.
<canvas id="player1BallOn" class="player1BallOn">
</canvas>
<canvas id="player2BallOn" class="player2BallOn">
</canvas>
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);
}
}
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.
#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.
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:
var srotate = "rotate(" + renderStep * 10 + "deg)";
$("#roofFan").css({ "-moz-transform": srotate,
"-webkit-transform": srotate, msTransform: srotate });
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.
#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.
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.
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;
}
.
.
.
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:
if (!cueBall.pocketIndex) {
context.strokeStyle = '#888';
context.lineWidth = 4;
context.lineCap = 'round';
context.beginPath();
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:
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;
}
}
}
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.
var tracingQueue = new Queue();
As long as the balls are rendered, we store the cue ball position in the queue:
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.
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:
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();
}
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.
Each ball is drawn by a common function, that takes two parameters:
- the canvas context and
- 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.
function drawBall(context, ball, newPosition, newSize) {
var position = ball.position;
var size = ball.size;
if (newPosition != null)
position = newPosition;
if (newSize != null)
size = newSize;
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 );
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) {
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();
}
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.
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 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.
function resolveCollision(ball1, ball2) {
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);
var mass = 0.5;
var im1 = 1.0 / mass;
var im2 = 1.0 / 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)));
var v = ball1.velocity.subtract(ball2.velocity);
var vn = v.dot(mtd.normalize());
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);
if (!ball1.isFixed)
ball1.velocity = ball1.velocity.add(impulse.multiply(im1));
if (!ball2.isFixed)
ball2.velocity = ball2.velocity.subtract(impulse.multiply(im2));
}
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:
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.
function resolveCollision(ball1, ball2) {
.
.
.
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)));
.
.
.
if (!ball1.isFixed)
ball1.velocity = ball1.velocity.add(impulse.multiply(im1));
if (!ball2.isFixed)
ball2.velocity = ball2.velocity.subtract(impulse.multiply(im2));
}
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 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
).
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:
$('#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:
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.play();
}
.
.
.
}
Finally, when a ball falls into a pocket, we play the "fall.mp3" sound:
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();
}
}
}
}
}
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:
jQuery(document).ready(function () {
...
retrieveGameState();
...
On the other side, we always save the game state after processing the points of each shot:
function render() {
...
processFallenBalls();
saveGameState();
...
}
Local storage is implemented as a dictionary of string
s. 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:
function saveGameState() {
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:
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:
[{"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:
function retrieveGameState() {
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 (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");
}
}
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.
- 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