Advanced SVG Animation
This topic looks at more advanced concepts of creating SVG animation for your website. Before you work through this tutorial, be comfortable with Basic SVG Animation, Intermediate SVG Animation, and have a solid understanding of HTML and JavaScript.
Note To view the examples in this topic, you need a browser, such as Windows Internet Explorer 9 and later, that supports the SVG element.
In this topic, we expand the circular ball arena, described in Intermediate SVG Animation, into a pedagogically centered two-dimensional video game:
The objective of the game is straightforward – bounce the balls off the paddle, such that they bounce through the goal (the gap in the outer wall) on the left-hand side of the circular arena. A ball can only enter the goal after it touches the paddle. When a ball touches the paddle, it becomes “hot” and changes color from a “cold” white to a non-white (randomly chosen) “hot” color. For more information and game playing tips, see Game Overview.
To try the game out, click SVG Ball Bounce. As discussed in Game Overview, clicking anywhere on the page starts the game; moving the mouse vertically up and down moves the paddle up and down.
Now that you’ve taken a look at the game, we’ll discuss how it was built in the order in which it was implemented:
- Example 1 – Static Framework
- Example 2 – Paddle Movement
- Example 3 – Ball Placement
- Example 4 – Ball Movement
- Example 5 – Scoring & Level Advancement
- Example 6 – Liquid Layout
- Debugging
- Cross-browser Support
- Suggested Exercises
In the following discussion, it is helpful to know that the coordinate system used in the game is pseudo-Cartesian in the sense that the game’s coordinate system has its origin in the center of the arena (as in a standard Cartesian coordinate system) but the y-axis is positive below the x-axis and negative above the x-axis.
Also, be aware that polar coordinates are often used in that they can significantly simplify much of the associated mathematics:
Figure 1.
Because of their length, none of the source code for the following examples is shown in this topic. Instead, a" Live link" is provided for each example. To view the source code associated with an example, use the view source feature of your browser. For example, in Windows Internet Explorer, right-click the webpage whose source code you want to view and click View source. Make sure that you have the appropriate source code available while you read the remainder of this document.
Example 1 – Static Framework
The game was built with a static game framework as its foundation:
Figure 2.
This framework is based on the following markup:
<body id="gamePage" onload="game.init();">
<div id="rules" class="roundCorners">
<p><a href="rules.html" target="_blank">Game Overview</a></p>
</div>
<div id="clock" class="roundCorners">
<p>Seconds Remaining:</p>
</div>
<div id="score" class="roundCorners">
<p>Score: 0</p>
</div>
<div id="level" class="roundCorners">
<p>Level: 1</p>
</div>
<div id="messagingBox" class="roundCorners">
<h3>SVG Ball Bounce</h3>
<p>Dare to get your balls in the goal, to score!</p>
</div>
<div id="arenaWrapper">
<svg id="arenaBox" width="800px" height="800px" viewBox="0 0 800 800">
<g id="arena" transform="translate(400, 400)">
<circle id="floor" cx="0" cy="0" />
<path id="wall" />
<circle id="post" cx="0" cy="0" />
<rect id="paddle" x="300" y="-50" width="8" height="100" rx="2" ry="2"/>
</g>
</svg>
</div>
</body>
This markup is relatively straightforward but the following items may be of interest:
- The content of the three
Time
,Level
, andScore
boxes (implemented by using<div>
elements) are programmatically changed, and all four useposition:absolute
CSS to position the boxes in the four corners of the webpage. - The default SVG coordinate system is modified to a pseudo-Cartesian coordinate system (as mentioned previously) by the transform attribute in
<g id="arena" transform="translate(400, 400)">
.
From a JavaScript perspective, the fundamental logic flow of the game is as follows:
- After the page fully loads,
Game.prototype.init
is invoked, which draws the arena (Arena.prototype.draw
) and starts the game (Game.prototype.start
). Game.prototype.start
callsrequestAnimationFrame
to set up a callback function toGame.prototype.play
that will do the majority of the work, andsetInterval
to set up a callback function toGame.prototype.clock
that updates the game clock, in the upper right-hand box, once per second. Note the requirement, withinGame.prototype.start
, to create a JavaScript closure (i.e.,var gameObject = this;
) so that the functions invoked byrequestAnimationFrame
andsetInterval
will access the correctthis
pointer.- Thanks to
Game.prototype.start
,Game.prototype.play
is called (viarequestAnimationFrame
) about every 16.7 milliseconds (i.e., about 60 FPS).Game.prototype.play
is currently an empty function but will eventually contain the main game loop. Game.prototype.clock
is called once per second and decrements any remaining game time. When the game time is less than or equal toconstants.warningTime
, the background of the circular game arena flashes red. This effect is accomplished by first setting the arena floor to red and then setting up another callback function (i.e.,Arena.prototype.defaultFloorColor
) to change the floor color back to white after a short amount of time.setTimout
is used for this purpose because its callback function is only invoked once, which is exactly the behavior we need to pulse the arena floor, once per second.
Finally, be aware of the last four lines in the game constructor function:
function Game(level) {
this.paused = true;
this.ended = false;
this.level = level;
this.score = 0;
this.time = constants.playTime;
this.arena = new Arena(this);
this.goal = new Goal(this);
this.paddle = new Paddle(this);
this.balls = new Balls(this);
}
These lines allows the balls, paddle, goal, and arena objects access to the game object itself. This becomes useful when one object must access another. For example, in Arena.prototype.draw
, we update the clock as follows:
updateClock(this.game.time);
Here the arena object accesses its game object property in order to access the game object’s time property.
Example 2 – Paddle Movement
In this example, the paddle moves either by pressing the up arrow key or down arrow key or by making vertical mouse movements.
To start, be aware that all the JavaScript helper functions used in Example 1 have been moved to the external file helpers.js
(hence <script src="helpers.js" type="text/javascript"></script>
used in Example 2). You might notice additional helper functions in helpers.js
as well – these will be used in later examples.
Note To view the contents of an external JavaScript file, place the appropriate path to the JavaScript file in your browser and either open or save it. For example, to view helpers.js
we see from <script src="helpers.js" type="text/javascript"></script>
that helpers.js
is in the same directory as example2.html
. So if example2.html
is in http://samples.msdn.microsoft.com/Workshop/samples/svg/svgAnimation/advanced/example2.html, the required path to open helpers.js
is http://samples.msdn.microsoft.com/Workshop/samples/svg/svgAnimation/advanced/helpers.js. Be aware that to view the JavaScript file, you might need to save the file locally first and then open it in Notepad (or the alike).
In Example 1, the game starts without any user input. In Example 2, the game only starts if an up or down arrow key is pressed or the mouse is clicked. The basic logic for this new functionality is as follows:
Just after the page is fully loaded, Game.prototype.init
sets up the following event handlers:
window.addEventListener('keydown', processKeyPress, false);
window.addEventListener('click', processMouseClick, false);
Shifting to helpers.js
, you’ll notice that the functions processKeyPress
and processMouseClick
both directly access the global variable game
. These were made helper functions (as opposed to game methods) because they are, in a sense, logically outside of the game.
processKeyPress
is straightforward – when the correct key is pressed, it invokes Paddle.prototype.keyMove
, which moves the paddle by constants.paddleDy
units in an appropriate direction (i.e., either up or down). Paddle.prototype.keyMove
also ensures that the paddle does not go beyond the outer arena wall. This is accomplished using the Pythagorean Theorem:
Figure 3.
From figure 3, we see that r² = x² + y² ⇒ y² = r² – x² ⇒ y = √(r² – x²), which gives the maximum height of the paddle. Thus, we have:
var maxY = Math.sqrt( (constants.arenaRadius * constants.arenaRadius) - (paddle.x.baseVal.value * paddle.x.baseVal.value) );
Moving to processMouseClick
, when the mouse is clicked, the primary purpose of this function is to add a mouse move event listener (in this case, Game.prototype.mouseMove
). Game.prototype.mouseMove
moves the paddle up and down based on the current vertical position of the mouse via:
paddle.y.baseVal.value = evt.pageY - arenaTransformForY - constants.paddleFudgeFactor;
The core issue here is that the mouse’s y-position (evt.pageY
) is in one coordinate system and the paddle’s y-position (paddle.y.baseVal.value
) is in another. We transform the mouse’s y-position to the game’s pseudo-Cartesian coordinate system by subtracting the y-component of the transform that was applied to create the pseudo-Cartesian system in the first place:
<g id="arena" transform="translate(400, 400)">
In Paddle.prototype.mouseMove
, this is acquired through the somewhat cryptic line:
var arenaTransformForY = arena.transform.baseVal.getItem(0).matrix.f;
This line is simply acquiring the second 400
in the previous <g> tag. We subtract from this 400 an empirically derived constant that centers the paddle under the mouse click. Now, as the mouse vertically moves, so does the paddle.
Example 3 – Ball Placement
The next step is to create the balls, position them within the "donut" shaped arena such that none of them overlap with each other or with the paddle, and append them to the DOM (making them appear on the screen). This is accomplished using the following method:
Balls.prototype.place = function() {
this.create();
this.positionInArena();
this.appendToDOM();
}
The three methods invoked by this code are relatively straightforward with the following possible exceptions:
In
Balls.prototype.create
, the helper functiongetRandomArenaPosition(ballElement.r.baseVal.value)
positions the balls in the arena using polar coordinates:function getRandomArenaPosition(ballRadius) { var p = new Point(0, 0); // This constructor defined in vector.js var r = constants.arenaRadius; var allowableRandomRadius; var randomTheta; allowableRandomRadius = getRandomInteger(constants.postRadius + ballRadius, r - ballRadius); randomTheta = getRandomReal(0, 2*Math.PI); p.x = allowableRandomRadius * Math.cos(randomTheta); p.y = allowableRandomRadius * Math.sin(randomTheta); return p; }
The following diagram helps to explain this code example:
Figure 4.
In figure 4, a =
constants.postRadius
, b =ballRadius
, r = r (the radius of the outer arena wall), and θ =randomTheta
.allowableRandomRadius
is randomly chosen such that it is greater than or equal to a + b and less than or equal to r – b.randomTheta
is randomly chosen to be between 0 and 2π radians (360 degrees). This gives a random point (r, θ), see Figure 1, that is between the inner dashed circle and the outer dashed circle as suggested by the point (p.x, p.y) in Figure 4. The point (r, θ) is then converted to rectangle coordinates using the standard equations x = r cos(θ) and y = r sin(θ).Be aware that
vector.js
was introduced in Example 3 becausegetRandomArenaPosition
returns an objectp
of typepoint
(specifically,function Point(x_coordinate, y_coordinate)
). Additionally,vector.js
contains implementations for common vector operations that will be used in subsequent examples. See Vector for more information about these common vector operations.In
Balls.prototype.positionInArena
,Paddle.prototype.hasCollided
is called, which determines if the given ball has collided with the paddle. The technique used in the following code example is as follows:- Detect if the ball has not collided with the paddle, and return
false
if it indeed did not collided with the paddle. - Detect if the ball has collided with one of the flat surfaces of the paddle such that the center of the ball is not within a corner zone, and return
true
if so. - Detect if the ball has struck one of the paddle corners by using the Pythagorean Theorem, and return
true
if so.
For the following code example, be aware that
ball_cx(ball)
,ball_cy(ball)
, andball_r(ball)
return the x-coordinate of the ball’s center, the y-coordinate of the ball’s center, and the radius of the ball respectively:Paddle.prototype.hasCollided = function(ball) { /* Returns true if a ball has collided with the paddle, false otherwise. */ var paddle = document.getElementById('paddle'); // Needed for Firefox. Not needed for IE or Chrome. var p = new Object(); // To save on typing, create a generic object to hold assorted paddle related values ("p" stands for "paddle"). p.x = paddle.x.baseVal.value; // The x-coordinate for the upper left-hand corner of the paddle rectangle. p.y = paddle.y.baseVal.value; // The y-coordinate for the upper left-hand corner of the paddle rectangle. p.w = paddle.width.baseVal.value; // The width of the paddle rectangle. p.h = paddle.height.baseVal.value; // The height of the paddle rectangle. p.delta_x = Math.abs( ball_cx(ball) - p.x - p.w/2 ); // The distance between the center of the ball and the center of the paddle, in the x-direction. p.delta_y = Math.abs( ball_cy(ball) - p.y - p.h/2 ); // The distance between the center of the ball and the center of the paddle, in the y-direction. // See if the ball has NOT collided with the paddle in the x-direction and the y-direction: */ if ( p.delta_x > (p.w/2 + ball_r(ball)) ) { return false; } if ( p.delta_y > (p.h/2 + ball_r(ball)) ) { return false; } // See if the ball HAS collided with the paddle in the x-direction or the y-direction: */ if ( p.delta_x <= (p.w/2) ) { return true; } if ( p.delta_y <= (p.h/2) ) { return true; } // If we've gotten to this point, check to see if the ball has collided with one of the corners of the paddle: */ var corner = new Object(); // A handy object to hold paddle corner information. corner.delta_x = p.delta_x - p.w/2; corner.delta_y = p.delta_y - p.h/2; corner.distance = Math.sqrt( (corner.delta_x * corner.delta_x) + (corner.delta_y * corner.delta_y) ); return corner.distance <= ball_r(ball); }
- Detect if the ball has not collided with the paddle, and return
Example 4 – Ball Movement
Ball movement requires a lot of new code because we must now deal with the following issues:
- Balls colliding with other balls.
- Balls colliding with the arena (both the inner and outer walls).
- Balls colliding with the paddle.
Balls Colliding with Other Balls
Collisions between balls are discussed in Intermediate SVG Animation via Example 4. For more information, see the "Have Collision, Will Travel" section of Collision Response (PowerPoint).
Balls Colliding with the Arena
Collisions between balls and the arena are covered in Intermediate SVG Animation via Example 5. However, you will notice some changes to the while ( this.hasCollided(ball) )
loop within Arena.prototype.processCollision
– this is discussed in the Debugging section later in this topic. Additionally, Arena.prototype.hasCollided
has straightforwardly been extended to return true
if the ball strikes either the inner or outer arena wall as follows:
return (d + r >= constants.arenaRadius) || (d - r <= constants.postRadius);
Balls Colliding with the Paddle
Collisions between balls and the paddle are relatively clear-cut in the sense that, regardless of the angle the ball makes with the paddle, the ball will always bounce off at the same fixed angle, as shown in the following figure:
Figure 5.
This fixed post-bounce angle (Vout) is a function of the point at which the ball originally struck the paddle and is calculated using a simple linear equation:
Figure 6.
The x-axis represents the vertical length of the paddle with the top being zero and the bottom occurring at paddle.height.baseVal.value
. The y-axis represents the angle the ball will have when it bounces off (a vertical surface of) the paddle.
If we represent the linear equation as the function ƒ, from Figure 6 we see that ƒ(0) = 45. In this case, the ball has struck the top of the paddle (x = 0) and will be deflected at 45 degrees (y = 45) regardless of the ball’s incoming angle (as indicated in Figure 5).
Likewise, ƒ(paddle.height.baseVal.value
) = -45. Here, the ball has struck the bottom of the paddle and is deflected at -45 degrees.
If the ball strikes the center of the paddle, we have x = paddle.height.baseVal.value
/2 and ƒ(x) = 0, meaning that the ball will be deflected off the paddle at exactly 0 degrees (horizontally).
The linear equation ƒ assumes the ball is coming from the right when it strikes the paddle. If the ball is coming from the left and strikes either the top or the bottom of the paddle, the ball is deflected as shown in the following figure:
Figure 7.
ƒ is implemented in Paddle.prototype.verticalBounce
and this code takes into account whether or not the ball strikes the paddle from the left or the right as follows:
if (ball.v.xc >= 0) // The ball has struck the left (vertical) side of the paddle.
uAngle = 180 - uAngle;
Once the correct deflection angle is known, a unit vector u is calculated from it and the magnitude of the ball’s incoming velocity vector Vin (see figure 5) is transferred to the unit vector that produces the ball’s new outgoing velocity vector Vout. That is, if u is the calculated unit vector, and Vin is the incoming velocity vector, the outgoing velocity vector Vout = |Vin|u
To conclude this section, note that the main game loop finally has something in it:
Game.prototype.play = function() {
for (var i = 0; i < this.balls.list.length; i++)
{
this.balls.list[i].move();
this.balls.processCollisions(this.balls.list[i]);
this.paddle.processCollision(this.balls.list[i]);
this.arena.processCollision(this.balls.list[i]);
}
var that = this; // Preserves the correct "this" pointer.
this.requestAnimationFrameID = requestAnimationFrame(function () { that.play(); });
}
The algorithm is simple, for every ball in the ball list:
- Move the ball by a small amount.
- Process any collisions between balls.
- Process any collisions between balls and the paddle.
- Process any collisions between balls and the arena.
This main loop, which drives the entire game, occurs about every 16.7 milliseconds (i.e., about 60 FPS), thanks to the requestAnimationFrame
calls in Game.prototype.start
and Game.prototype.play
.
And Game.prototype.start
is invoked only when the game is started through pressing an up or down arrow key or through clicking the mouse (see processKeyPress
and processMouseClick
in helpers.js
).
Example 5 – Scoring & Level Advancement
The last major components to add to the game are scoring and level advancement.
Scoring
Recall from the game overview that you cannot score until a ball has first struck the paddle (and hasn’t subsequently bounced off the arena walls too many times). To track which balls are hot (can enter the goal) and which are cold (cannot enter the goal), we add the custom property hotCount
to each ball object. Initially, this ball property is undefined until the ball strikes the paddle and is set to ball.hotCount = constants.hotBounces
in Paddle.prototype.processCollision
. When a ball strikes an arena wall, this property is decremented. hotCount
is also used to provide a visual cue as to the number of hot bounces a particular ball has left. When a ball first touches the paddle, it is changed from a "cold" white fill color to a random "hot" non-white fill color in Paddle.prototype.processCollision
as follows:
if ( !ball.hotCount )
ball.style.fill = getRandomColor();
Here the ball is considered cold if ball.hotCount
is undefined
, which is the case when a ball is first created (recall that !undefined
is true
in JavaScript) or when ball.hotCount
is zero – which occurs when the ball has bounced off of the arena walls constants.hotBounces
or more times.
At each arena wall bounce, a ball’s hotCount
and opacity is decreased in Arena.prototype.processCollision
(until they eventually reach zero) as follows:
if (ball.hotCount > 0) {
--ball.hotCount;
updateMessagingBox("Ball " + ball.i + ": " + ball.hotCount + " bounces left to score.");
ball.style.fillOpacity = ball.hotCount / constants.hotBounces;
if (ball.style.fillOpacity == 0)
ball.style.fill = constants.coldBallColor;
}
This per ball hotCount
property is used to determine if a ball can enter the goal as well:
Goal.prototype.processCollision = function(ball) {
if ( this.hasCollided(ball) && ball.hotCount > 0 && !ball.inactive) {
updateMessagingBox("Score! (ball " + ball.i + " gone)");
++this.game.score;
updateScoreBox(this.game.score);
ball.poof();
ball.inactive = true
if ( this.game.balls.allInactive() )
this.game.nextLevel();
}
}
Level Advancement
As we move from level to level, the number of balls in the ball list increases (one ball per level). On a level with multiple balls (level 2 and above), when a ball enters the goal it needs to be removed from play in some way. The original approach was to simply remove a scoring ball from the ball list but this resulted in problematic code synchronization issues. To avoid rewriting much of the code, one can mark each scoring ball as inactive and remove these balls from the playing area by moving them outside of the outer arena wall, then gracefully setting their velocity and radius to zero, which is the responsibility of ball.poof()
as shown previously. Be aware that when you use this approach, there are additional checks within many of the game playing loops (search for "inactive" within the JavaScript code to find these loops).
When the ball list is composed of nothing but inactive balls (and there’s still time on the clock), we know that it’s time to move to the next level (and reset the clock). Because Goal.prototype.processCollision
will be the first method to detect this state, we place this code within it as shown in the last two lines of the prior code snippet.
Example 6 - Liquid Layout
The final feature to add to the game is "liquid layout". With liquid layout implemented, as the browser’s window size changes, the size of the game changes accordingly. This allows the game to more easily be played on devices with smaller screens or resolutions.
In example 5's Paddle.prototype.mouseMove
method, we are dealing with two coordinate systems:
Screen coordinates - the coordinate system associated with the mouse's position on the screen (with the origin occurring in the upper left corner of the browser's window).
Arena coordinates - the coordinate system associated with the game arena as defined by this markup:
<svg id="arenaBox" width="800px" height="800px" viewBox="0 0 800 800"> <g id="arena" transform="translate(400, 400)"> <circle id="floor" cx="0" cy="0" /> <!-- The arena floor --> <path id="wall" /> <!-- The arena wall less the goal hole. --> <circle id="post" cx="0" cy="0" /> <!-- The central post in the middle of the arena. --> <rect id="paddle" x="300" y="-50" width="8" height="100" rx="2" ry="2"/> <!-- Ball circle elements are appended here via JavaScript. --> </g> </svg>
Within Paddle.prototype.mouseMove
, we map the mouse's (vertical) position evt.pageY
(in screen coordinates) to the coordinate system associated with the arena (which includes the paddle) using paddle.y.baseVal.value = evt.pageY - arenaTransformForY - constants.paddleFudgeFactor
. Because the arena size is fixed (800x800), we can get away with this coordinate transformation "hack" in that the meaning of arenaTransformForY
(in arena coordinates) remains constant relative to evt.pageY
. However, when the layout becomes liquid, we can no longer make this assumption. Instead, we need a general way to translate from one coordinate system (screen coordinates) to another (arena coordinates).
Fortunately, SVG provides a relatively easy way of doing this. The technique is covered in SVG Coordinate Transformations and should be understood before proceeding. With this understanding in place, the following changes where made to example 5 to produce its liquid version, example 6:
CSS Modifications
In base.css, the following percentage-based values were added:
html {
padding: 0;
margin: 0;
height: 100%; /* Required for liquidity. */
}
body {
padding: 0;
margin: 0;
background-color: #CCC;
height: 100%; /* Required for liquidity. */
}
body#gamePage svg {
margin-top: 0.8%; /* Used to provide a liquid top margin for the SVG viewport. */
min-height: 21em; /* Don't let the playing arena get preposterously small. */
}
body#gamePage #arenaWrapper {
text-align: center;
height: 80%; /* Required for liquidity. */
}
Basically, html
's height: 100%
ensures that the page will always be as large as the browser's window. body
's height: 100%
ensures that the page's content will always be as large as its container (the html
element). As mentioned in the comments, body#gamePage svg
's margin-top: 0.8%
provides a liquid top margin for the SVG viewport, and height: 80%
on body#gamePage #arenaWrapper
provides the analogous bottom margin.
Markup Modifications
For the svg
element, we change the width
and height
attribute values from 800
to 100%
as follows:
<svg id="arenaBox" width="100%" height="100%" viewBox="0 0 800 800">
This, in conjunction with the previous CSS changes, allows the SVG element's viewport to scale to the browser's window size.
JavaScript Modifications
In helpers.js, a coordinate transformation function was added:
function coordinateTransform(screenPoint, someSvgObject) {
var CTM = someSvgObject.getScreenCTM();
return screenPoint.matrixTransform( CTM.inverse() );
}
As explained in SVG Coordinate Transformations, this function maps the mouse's position (screenPoint
) to the coordinate system associated with someSvgObject
, which in this case, is the arena's (and thus paddle's) coordinate system.
Lastly, in example6.html, Paddle.prototype.mouseMove
was modified as follows (note comments):
Paddle.prototype.mouseMove = function(evt) {
if (this.game.ended || this.game.paused)
return;
var paddle = document.getElementById('paddle');
var arena = document.getElementById('arena');
var maxY = Math.sqrt( (constants.arenaRadius * constants.arenaRadius) - (paddle.x.baseVal.value * paddle.x.baseVal.value) );
var arenaBox = document.getElementById('arenaBox');
var point = arenaBox.createSVGPoint(); // Create an SVG point object so that we can access its matrixTransform() method in function coordinateTransform().
point.x = evt.pageX; // Transfer the mouse's screen position to the SVG point object.
point.y = evt.pageY;
point = coordinateTransform(point, arena); // Transform the mouse's screen coordinates to arena coordinates.
var paddleHeight = paddle.height.baseVal.value;
paddle.y.baseVal.value = point.y - (paddleHeight / 2); // Change the position of the paddle based on the position of the mouse (now in arena coordinates).
var paddleTop = paddle.y.baseVal.value;
if (paddleTop <= -maxY) {
paddle.y.baseVal.value = -maxY;
return;
}
var paddleBottom = paddleTop + paddleHeight;
if (paddleBottom >= maxY) {
paddle.y.baseVal.value = maxY - paddleHeight;
return;
}
}
Because the paddle uses the arena's coordinate system, we first transform the mouse's screen coordinate position to its analogous position within the arena coordinate system by calling the coordinateTransform
helper function. After the mouse's position has been transformed, it becomes trivial to change the position of the paddle based on the current position of the mouse: paddle.y.baseVal.value = point.y - (paddleHeight / 2)
. The paddleHeight/2
constant ensures that the paddle is moved from its center (as opposed to its top).
Debugging
SVG Ball Bounce was developed in Internet Explorer and IE's F12 Developer Tools were invaluable in tracking down and resolving JavaScript bugs.
For example, one of the more complex bugs that was encountered was an ostensibly random game lockup that occurred when a ball struck the paddle and the outer arena wall at about the same time but only in certain locations.
It seemed that the issue might have something to do with the "hacky" (and potentially infinite) while
loops in Arena.prototype.processCollision
, Paddle.prototype.processCollision
, and SVGCircleElement.prototype.collisionResponse
.
Placing calls to F12Log()
in each of the suspect while
loops as well as within the main game loop (Game.prototype.play
) to monitor their output in an F12 Developer Tools console window provided a means to determine exactly when and where the game locked up. These F12Log()
calls are still present in the code but are commented out (search for "F12Log").
After playing the game for a number of minutes, the game locked up and it became clear that the code was indeed falling into an infinite loop in a suspected while
loop within Arena.prototype.processCollision
:
while ( this.hasCollided(ball) ) {
F12Log("In Arena.prototype.processCollision while loop...");
ball.move(); // Normally move the ball away from the arena wall.
}
Clearly, and in certain situations, the effect of ball.move()
was not functioning as intended and instead caused this.hasCollided(ball)
to become true
, resulting in an infinite loop. The unsophisticated solution used is to detect this "infinite loop" state and then simply "remove" the problematic ball from the situation, as shown in the following code example:
var loopCount = 0;
while ( this.hasCollided(ball) ) {
// F12Log("In Arena.prototype.processCollision while loop...");
if (++loopCount > constants.arenaDeadlock)
ball.moveTowardCenter();
else
ball.move();
}
Here, we do a simple test to see if the while
loop has looped "too much" and if so, we "pick the ball up" and move it towards the center of the arena by a small amount. This breaks the infinite loop without overly disturbing the physics displayed to the player.
Additionally, the value of defensive programming was very helpful during game development. For example, in the helper function affirmingMessage
, you’ll note the following line:
alert("Error in affirmingMessage(" + level + ")");
You’ll notice similar alerts sprinkled throughout the code. In the case of the affirmingMessage
function, level = level % 7 + 1
was incorrectly used to ensure that level
stayed between 1 and 7. Without this defensive alert, it would have been much more difficult to figure out this coding error (the correct statement is level = ((level-1) % 7) + 1
).
Cross-browser Support
SVG Ball Bounce was developed using Internet Explorer 9. After the game was complete and fully functioning in Internet Explorer 9, the code was adjusted to work in Firefox and Chrome. Luckily, there were only a few cross-browser issues to deal with:
- Mouse Event Objects
- getElementById
- Color Strings (color value serialization)
Mouse Event Objects
In the prototype Paddle.prototype.mouseMove
, evt.y
was used to determine the mouse’s current y-coordinate position. Changing evt.y
to evt.pageY
provides the same information but is more cross-browser compatible. That is:
paddle.y.baseVal.value = evt.y - arenaTransformForY - constants.paddleFudgeFactor;
was changed to:
paddle.y.baseVal.value = evt.pageY - arenaTransformForY - constants.paddleFudgeFactor;
getElementById
Consider the following hypothetical function:
function notUsedInGame() {
paddle.y.baseVal.value = 40;
}
Assuming paddle
already exists, this function works as-is in both Internet Explorer 9 and Chrome. However, Firefox requires (at the time of this writing) the following getElementById
call:
function notUsedInGame() {
var paddle = document.getElementById('paddle');
paddle.y.baseVal.value = 40;
}
This explains the multitude of "anomalous" getElementById
calls within the code.
Color Strings
In Paddle.prototype.processCollision
, the following code was used to detect when to change a cold white ball to a non-white hot color:
if (ball.style.fill == constants.coldBallColor)
ball.style.fill = getRandomColor();
Despite the fact that each ball’s fill property is set to constants.coldBallColor ("white") when it’s initially created, the above conditional test fails across different browsers because the string "white"
is converted to #ffffff
in some browsers and rgb(255, 255, 255)
in others. The solution used was to abandon this cold color test and instead rely on a ball’s hotCount
property:
if ( !ball.hotCount )
ball.style.fill = getRandomColor();
In Paddle.prototype.processCollision
, this test is used to give a cold ball a solid non-white color only if:
ball.hotCount
is 0 (a hot ball has become cold)ball.hotCount
isundefined
(a newly created ball has struck the paddle causing it to become hot)
As mentioned previously, ball.hotCount
is 0 when a hot ball becomes cold due to excessive arena wall bounces; and ball.hotCount
is undefined
just after the balls are first created (and are all cold by default). This works out well because !undefined
is true
in JavaScript.
Suggested Exercises
To fully understand the code presented in this topic, the follow game improvements are suggested:
- Provide a means, through a button press or another way, to increase the speed of slow moving ball(s). For example, pressing the "s" key could increase the speed of all balls where the magnitudes of the associated velocity vectors are below some threshold value.
- Ensure that balls are given an initial velocity vector such that "boring" bounce patterns do not occur such as a "locked" bounce between the inner and outer arena walls.
- Add sound effects - see Example 8 of Basic SVG Animation.
More challenging improvements might include changing the rectangular paddle to an ellipse. This would provide a visually more accurate way of guiding the ball towards the goal. One approach for ball/paddle collision detection, in this case, might be simultaneously solving the system of equations associated with a ball’s circle and the paddle’s ellipse.