Introduction
In the original SpaceShoot article, the blueprint for a full featured HTML5 game was presented. This time we implement many of the ideas that were
presented previously for extending the game. This article will present the game itself and part of its code. We will walk through the following topics:
- Building a menu in HTML and controlling it over JavaScript
- Using
LocalStorage
to save settings and personal high-scores - Coding manager types to make the loading process easy to extend
- Integrating levels to make the game interesting
- Building the bomb (alternative weapon)
- Implementing CPU controlled ships
- Making the game informative with text
- Construction of a text control for displaying an intro
Before we touch any of these topics, we should have a look at the current state of the game.
Background
This article is a follow up article on SpaceShoot - A multiplayer game in HTML5, which can be found
here. The whole project originated from the idea
of building something using the WebSocket
element, which has been introduced lately. However, it was obvious that a full featured single-player mode would also be very useful.
There will be another article about the full implementation of the multiplayer modus. This one will mostly contain C#, since the server was written with
the help of Fleck, written by Jason Staten. Finally I am thinking about a mobile version as well.
This one could be a multiplayer only version on Windows Phone 7 or just using responsive design techniques in HTML / CSS.
The game itself
The concept of SpaceShoot is quite simple: The player controls a spaceship which is floating in space and attacked by asteroids and other ships. The game
is controlled by using the keyboard with the following keys:
- Arrow keys to accelerate (up), break (down), and steer left (left) and right (right)
- The Space key to shoot normal ammo
- The Ctrl key to deploy a bomb
- The score screen can be switched on and off using the Tab key
- The menu can be reached and left by using the Esc key
The player starts with full life and full ammo, but no bombs and no shield. In order to heal, refill ammo, get bombs, or power up the shield, the player
has to collect items that are presented occasionally. Those items are limited in their presence, i.e., they vanish after a certain time. This is displayed
by a degenerating progress bar below the items. These items can be collected:
- - Gives +30 health
- - Gives +20 ammo
- - Gives +2 bombs
- - Gives full shields
Collecting items will give points to the player. Also it is important to know that even though some packs might increase a certain property, e.g., health, ammo, or bomb count,
the property will still hold certain constraints, i.e., the property's value cannot exceed its maximum. Taking health, for instance, gives us a range from 0 (dead) to 100 (maximum health).
Collecting a health pack (+30 health) while having 87 health will therefore result in 100 health and not 117.
The shield is a special ability which will protect the spaceship. The shield will protect the ship by taking the damage. Its occurrence will increase with the level count.
This is important since the asteroid count will also increase with the level count. Also, computer controlled drones (which are send out regularly) will be more than
a pain in the higher levels. Every 15th level, a whole armada of drones is send out in order to destroy the player's ship. Those armadas grow proportional to the level count.
The computer controlled ships try to fly directly in the direction of the player's ship. If the player is close enough, they will fire in a regular interval.
They also fire if an asteroid is in their way. However, they do not try to make detours in order to avoid asteroids.
The code
The code did not change a lot (architecturally) since the last time. The major difference is that the code was extended in a lot of areas. It is also worth
noticing that some of the improvements I mentioned last time have been included in this version. All of the major extensions and improvements will be discussed in depth in this section.
Menu implementation
The menu is implemented using the DOM. Here we want to make use of HTML instead of just abusing the Canvas
element (after all, the styling possibilities given
by CSS should not be avoided but used as often as possible). Using jQuery would give us another production boost. However, in this case, jQuery was
excluded in order to develop the menu without using any external libraries. The basic HTML is build up like the following code:
<div id="menuscreen">
<p id="title">SpaceShoot</p>
<ul id="menu-main">
...
</ul>
<ul id="menu-scr">
...
</ul>
...
</div>
So we are just spawning a <div>
-element containing all of the menus. In the CSS, we are applying some fancy styling in order to keep everything
nice and harmonic. The different menu entries that will be shown are presented using <ul>
-lists. Let's find out how the events will be applied in our JavaScript code:
menu.init = function() {
document.getElementById('menu-sp').onclick = function() {
game.startSingle();
menu.toggle();
};
document.getElementById('menu-setname').onclick = function() {
menu.select('menu-user');
};
};
All menu functions are placed in an object called menu
. The init()
method will set up the menu, i.e., set the right submenu visible
and set all click callbacks for further usage. While some elements have a direct click-event like starting the single player (and then closing the menu), others
open other submenus. The opening is done by menu.select(id)
. Here the following code is executed:
menu.select = function(id) {
var m = document.getElementById('menuscreen');
var items = m.getElementsByTagName('ul');
for(var i = items.length; i--; ) {
var isel = items[i].id === id;
items[i].style.display = isel ? 'block' : 'none';
}
};
The method just opens the menu and gets all underlying submenus. Then all those submenus will be hidden, except the one that should be selected (shown).
With jQuery, such a command could have been written in one short line. The toggling of the menu (showing or hiding) is being done by the toggle()
method.
This one has been implemented in the following way:
menu.toggle = function() {
var m = document.getElementById('menuscreen');
var im = document.getElementById('inactive')
if(m.style.display === 'block') {
game.running = true;
m.style.display = 'none';
im.style.display = 'none';
c.canvas.focus();
} else {
game.running = game.running && game.multiplayer;
m.style.display = 'block';
im.style.display = 'block';
menu.select('menu-main');
m.focus();
}
};
The method checks if the menu is currently shown (over the display
rule of the menu screen <div>
). It then executes the right
statements depending on the visibility status. The <div>
-element with the ID inactive is just a layer that will be placed between the menu
and the canvas / body of the document. Therefore the rest of the document will have a look which indicates that the focus is now on the menu and that the menu
has to be closed in order to continue with the rest.
With CSS styling, it is easily possible to adjust the design of text boxes and headers in the menu. Since our JavaScript design is to invoke each button
with a (more or less) unique event, we can set up an event to catch the save settings link with an update of the local settings. All settings are stored
in the localStorage
object. This is a very useful concept that will be explained now.
Using LocalStorage
In order to save the settings and in order to save personal high-scores, we have to deal with cookies or other techniques. Using cookies is not a good idea,
since those are not only limited, but also kind of annoying to use. The localStorage
object is the solution to most problems encountered with cookies.
In order to limit access to the object (and therefore to limit JSON conversions), we buffer the current settings, as well as the current high-score in some local variables.
When the application loads, the previous high-scores will be loaded into a local array using the localStorage
object and JSON.
Unfortunately, we can only save variables of type DOMString
in the storage. However, this means that by using JSON,
we can store all JavaScript objects. The loading sequence is defined in the code as follows:
score.init = function() {
var s = JSON.parse(localStorage.getItem('highscores'));
if(s)
score.scores = s;
};
Now this is not very complicated. We get the saved high-scores from the localStorage
and save them in the local buffer if there were previous high-scores
saved (this is for sure not the case on first time loading). Otherwise, score.scores
is still just an empty array since the local variable s
will be undefined and therefore false
.
The current score screen can always be switched on or off within the game by pressing the TAB key. Once the game is over, the score screen is automatically shown.
An example score screen (with all possible statistics) is shown below.
In order to update the high-score list after a game, score.update()
is being called. This one will check if the current score is a new high-score.
If this is the case, a modal message dialog is shown. In any case, the current score is added to the list of scores. Additionally, the localStorage
object
is updated with the current list of scores in order to keep the scores up to date.
score.update = function() {
var dd = new Date();
var pts = myship.points;
if(pts > score.high().points) {
}
score.scores.push({ date : dd, points : pts, level: game.level, player : settings.playerName });
localStorage.setItem('highscores', JSON.stringify(score.scores));
};
Image and audio managers
As stated in the previous article, we need a more powerful manager for loading and obtaining images. Since the game requires audio in some scenarios, another manager
for audio objects was also required. This final single-player code contains an object resourceManager
, which creates instances of imageManager
and audioManager
to handle sprites, logos, and sound snippets. The resource manager object has been declared like this:
var resourceManager = new function() {
this.sounds = [
];
this.sprites = [
];
this.done = 0;
this.total = this.sounds.length + this.sprites.length;
infoTexts.push(new infoText(c.canvas.width / 2, c.canvas.height / 2,
100, "Loading 0%", secondaryColors[0]));
draw();
};
We need only to place the name of the files without ending and the directory in there. To inform the user that the loading process has started, an info text is placed
in the middle of the screen. The rest is done by executing the init()
method:
resourceManager.init = function() {
soundEffects = new soundManager(resourceManager.sounds, resourceManager.callback);
spriteSheets = new imageManager(resourceManager.sprites, resourceManager.callback);
};
So basically all managers are just started in there. One thing to notice right away is that we not only pass the array with the objects in, but also
a callback method. That method will handle all the magic, determining if all images are loaded and writing some useful output to the screen in order to inform the user.
resourceManager.callback = function() {
resourceManager.done += 1;
infoTexts.splice(0, 1);
if(ready = resourceManager.done === resourceManager.total)
resourceManager.ready();
else
infoTexts.push(new infoText(c.canvas.width / 2, c.canvas.height / 2, 100,
"Loading " + parseInt(100 * resourceManager.done / resourceManager.total) + "%",
secondaryColors[0]));
draw();
};
In the case where all external files (images and audio) are loaded, the ready()
method is called. This is the one that will perform the closing tasks.
In our case, the most important call there is to set up the network. This will not do anything in the current code (single-player). However, in the third article, this will
gain some importance (and it will look more like in the first article where we implemented a very simple multiplayer).
resourceManager.ready = function() {
network.setup();
};
Since the imageManager
object works as explained last time, we will have a look at the audioManager
this time. There are some major
differences between this manager and the one for images. First, let's take a look at the code:
var soundManager = function(sounds, callback) {
this.soundNames = sounds;
this.sounds = [];
this.count = sounds.length;
for(var i = 0; i < this.count; i++) {
var t = document.createElement('audio');
t.preload = 'auto';
t.addEventListener('loadeddata', function() {
callback();
}, false);
t.src = 'sounds/' + this.soundNames[i] + '.wav';
this.sounds.push([t]);
}
};
The constructor just takes the array with sound names and the method to call back when a sound file has been loaded successfully. In order to achieve this, we set various options.
One is to use the automatic preload. Another is to bind the event listener to the loadeddata
event. This will trigger the callback method once the whole audio snippet
is loaded. The load
event that is known from the imageManager
is not applicable here. At the end, the sound file is added to an array
(of <audio>
tags) that is kept by the local manager object.
The next thing we look at is the get()
method. All methods in the code can access the external files (placed in the appropriate tags) over the
get()
method. Let's look at it:
soundManager.prototype.play = function(name) {
if(settings.playSounds)
for(var i = this.count; i--; ) {
if(this.soundNames[i] === name) {
var t = this.sounds[i];
for(var j = t.length; j--; ) {
if(t[j].duration === 0)
return;
if(t[j].ended)
t[j].currentTime = 0;
else if(t[j].currentTime > 0)
break;
t[j].play();
return;
}
var s = document.createElement('audio');
s.src = t[0].src;
t.push(s);
s.play();
return;
}
}
};
Now that looks fancy! Why are we using so much code? Wouldn't a simple this.sounds[i].play()
followed by a return
statement do the job?
The answer here is obviously no - however, the reason is interesting: The audio tag (luckily) cannot play multiple times or be mixed.
At the moment, Google is doing a lot of coding there in order to come up with another tag to overcome all those issues, i.e., making the HTML standard more suitable
for audio in games. Since most of their implementations are restricted to Google Chrome and since all of those experiments are still in a very early stage, we tried
to overcome this limitation with this workaround.
Basically, the code just looks if all fitting audio tags is already playing the snippet. If it is, a clone tag will be created and added to the array.
This fresh clone tag will then play the sound snippet. A tag is fitting if the sound name is equal to the requested name. Images can be used multiple times,
so this work around is only necessary for the audioManager
.
Including items and levels
In order to make the game more interesting, it is necessary to give the player something to be proud of: like levels or items (refreshing health, ammo, or others).
All those things have been implemented in the game loop and will be called at the end of the circle before the draw()
method. Let's have a short look at the method to be called:
var items = function() {
if(game.ticks % 200 === 0) {
game.level++;
}
var gt = (game.ticks * game.level);
if(gt % 100 === 0)
generateAsteroid();
if(gt % 500 === 0)
generateDrone();
if(game.ticks % 50 === 0) {
var coin = Math.random();
if(coin < 0.1)
generateHealthpack();
else if(coin < 0.2)
generateAmmopack();
}
};
A cool possibility has been added with the bomb packs. When having a bomb pack (consisting of two bombs), it is possible to deploy one of those bombs. They will detonate after four seconds.
More possibilities
Since just flying around with just one weapon is really boring, it is necessary to include some other (fancy) weapon. In this case, we pick a completely different one: the bomb.
The characteristic is quite simple: the player does not shoot the bomb - instead he deploys it. The bomb does not detonate instantly, it gives the player some time
to escape from the bomb's detonation radius. This is mandatory since the bomb will also damage the player. The damage of the bomb is calculated by a square formula.
This is because the damage of the bomb is inverse proportional to the area it covers. The area is a circle in this case, i.e.,we have some square relation to the bomb's damage radius.
Several extensions are required in order to include another weapon. One extension is another item. This is implemented the same way that ammo packs and other packages have
been included. Another requirement is that the ship has an attribute that contains the number of bombs. Also the ship's logic has to look if some key is pressed
and deploy a bomb - if the player has at least one bomb.
Set up an AI
The computer controlled drones have to be intelligent (do not be afraid - it is not Skynet time yet!). In order to save some important CPU cycles
and in order to make the game still enjoyable, we implement a really rudimentary method in order to keep the movements in an obtrusive way. Our logic follows these simple conditions:
- If an asteroid is directly in the flying path (and closer than a certain distance), the drone has to shoot
- Calculate the angle to fly to the player's ship
- If the angle is bigger than a certain tolerance, start rotating
- Otherwise if the player's ship is below a certain distance, then shoot it
This has been implemented like the following:
drone.prototype.logic = function() {
var ta = d2g(this.angle);
for(var i = asteroids.length; i--; ) {
t1 = asteroids[i].x - this.x;
t2 = asteroids[i].y - this.y;
d = Math.sqrt(t1 * t1 + t2 * t2);
beta = Math.acos((Math.sin(ta) * t1 + Math.cos(ta) * t2) / d);
if(this.cooldown === 0 && beta < tol2 && d < bomb2) {
this.cooldown = DRONE_INIT_COOLDOWN;
particles.push(new particle(this.x, this.y,
(3 + this.speed) * Math.sin(ta), - (3 + this.speed) * Math.cos(ta), this));
break;
}
}
var f = this.x > ships[0].x ? 1 : -1;
t1 = this.x - ships[0].x;
t2 = this.y - ships[0].y;
d = Math.sqrt(t1 * t1 + t2 * t2);
beta = Math.acos((Math.sin(ta) * f * t1 + Math.cos(ta) * t2) / d);
if(beta > tol) {
this.angle = this.angle + f * ROTATE;
} else if(this.cooldown === 0 && d < MAX_BOMB_RADIUS) {
this.cooldown = DRONE_INIT_COOLDOWN;
particles.push(new particle(this.x, this.y,
(3 + this.speed) * Math.sin(ta), - (3 + this.speed) * Math.cos(ta), this));
}
};
This AI is just a funny start and has some silly consequences. On the one hand the drones will certainly be a pain in the neck (consider 150 drones streaming
in at level 150), on the other side this will also help the player to destroy asteroids. This funny side effect has also been displayed on a blocking info text that
will display on every wave of drones. More on that later.
Fading texts
In order to display small information texts in an unobtrusive and cool way, we have to introduce a new object: infoText
. The basic constructor
is very simple and has the following source:
var infoText = function(x, y, time, text, color) {
this.x = x;
this.y = y;
this.time = time;
this.total = time;
this.text = text;
this.color = color;
};
Basically, we just set the (center) position of the text as well as the color and the time it has. We store the time twice in order to perform decrements
on one time and still have the original value. This is then used in order to determine the current alpha status of the color, where 1 is the starting value
and 0 is the final value. The drawing is performed in the draw()
method:
infoText.prototype.draw = function() {
c.save();
c.translate(this.x, this.y);
c.textAlign = 'center';
c.fillStyle = 'rgba(' + this.color + ', ' + (this.time / this.total) + ')';
c.fillText(this.text, 0, 0);
c.restore();
};
Blocking text
The fading text is a nice feature, but it is not suitable for an intro or a credit screen or something else that has to block the game's logic.
An additional point for another type of text is that some texts should slowly be displayed in order to avoid giving the player a screen full of letters.
The solution is the introText
object that has been set up in the following way:
var introText = function(sx, sy, maxwidth, maxheight, lineheight, textArray) {
this.fulltext = textArray;
this.currentline = 0;
this.currentindex = 0;
this.lines = textArray.length;
this.linelength = textArray.length > 0 ? textArray[0].length : 0;
this.text = [''];
this.font = '20px Orbitron';
this.fillcolor = 'rgb(255, 255, 255)';
this.strokecolor = 'rgb(0, 0, 0)';
this.x = sx;
this.y = sy;
this.lineheight = lineheight;
this.width = maxwidth;
this.height = maxheight;
this.fadetime = 50;
};
Here we see that a lot of properties are actually set up. The constructor call basically includes some text to be shown at some starting position (x
and y
) as well as the maximum width and maximum height of the text. Also, the height of the line has to be specified. It is important to note that
the different lines have to be split up in an array of text, where each entry contains one line of text.
Right now the canvas is not as convenient as the text drawing methods in GDI+ (included in the .NET Framework). One drawback is that it does not automatically
include line breaks or options for specifying the maximum width of a text. The only option that we have right now is to measure the text and reduce the amount
of characters in a line depending on the result of the text measurement.
The code will do the following:
- Is the current line finished? Then go to the next line.
- Is there no next line? Then start fading away.
- Calculate the current index and add the next word.
- If the width of the text from the beginning to the next word is bigger than the line, then start a new display line.
- Append the next letter to the current display line.
- Increment the position index.
The code will work if at least one line of text has been passed as an argument. Most of the code can for sure be used without the effect of displaying character
by character. Basically, it could be used in a loop in order to display some text in a box on a <canvas>
element.
introText.prototype.logic = function() {
if(this.currentindex === this.linelength) {
this.currentline += 1;
this.text.push('');
this.currentindex = 0;
if(this.currentline < this.lines)
this.linelength = this.fulltext[this.currentline].length;
}
if(this.currentline === this.lines)
return --this.fadetime;
var idx = this.text.length - 1;
var text = this.text[idx];
var line = this.fulltext[this.currentline];
var chr = line[this.currentindex];
var next = line.indexOf(' ', this.currentindex);
var plus = '';
if(next > 0)
plus = line.substring(this.currentindex, next);
else if(next < 0)
plus = line.substring(this.currentindex);
c.save();
c.font = this.font;
if(c.measureText(text + plus).width > this.width)
this.text.push(chr);
else
this.text[idx] = text + chr;
c.restore();
this.currentindex += 1;
return true;
};
Integrating social connectors
A completely new feature is the social bar in the main menu. Please note that this is not a new thing in general: it is just new feature for SpaceShoot (compared to the original article). Such connectors may help any game to become known, since they allow people to share the Website with no effort at all. Let's have a look at the HTML to include the (very basic) social plugins of Facebook, Google+ and Twitter:
<div id="promo">
<a href="https://twitter.com/share" class="twitter-share-button" data-url="http://html5.florian-rappl.de/SpaceShootSingle/" data-lang="en" data-count="vertical">Tweet</a>
<div class="g-plusone" data-size="tall" data-href="http://html5.florian-rappl.de/SpaceShootSingle/"></div>
<div class="fb-like" data-href="http://html5.florian-rappl.de/SpaceShootSingle/" data-send="false" data-layout="box_count" data-width="60" data-show-faces="false" data-action="like"></div><div id="fb-root"></div>
</div>
</div>
This is the code that one should officially include in the website in order to make the JavaScripts work. In order to have those external JavaScripts on a combined place we just locate them in a file called promo.js. This file contains the following snippet of JavaScript:
!function(d,s,id) {
var js,fjs=d.getElementsByTagName(s)[0];
if(!d.getElementById(id)) {
js=d.createElement(s);
js.id=id;
js.src="http://platform.twitter.com/widgets.js";
fjs.parentNode.insertBefore(js,fjs);
}}(document,"script","twitter-wjs");
(function(d, s, id) {
var js,fjs=d.getElementsByTagName(s)[0];
if(d.getElementById(id))
return;
js=d.createElement(s);
js.id=id;
js.src="http://connect.facebook.net/en_US/all.js#xfbml=1";
fjs.parentNode.insertBefore(js,fjs);
}(document, 'script', 'facebook-jssdk'));
(function() {
var po=document.createElement('script');
po.type='text/javascript';
po.async=true;
po.src='https://apis.google.com/js/plusone.js';
var s=document.getElementsByTagName('script')[0];
s.parentNode.insertBefore(po, s);
})();
The source code has been modified slightly. However, the main purpose did not change at all. Each of those scripts behaves similarly: They all create a script tag and set the appropriate target source. One remarkable thing here is that only Google's script performs an async operation (if available). This is something that is missing on the other scripts and should be included in the final version of SpaceShoot (or any website).
Points of interest
In order to make the game work perfectly together with a server, some changes in the code are required. Those changes will be discussed in detail in the next article.
Most of those changes imply custom generation of objects like asteroids, ships, and others, as well as changes in the game loop. The live version also contains some social
integrations in order to be shared more easily and some methods to make cheating in the single-player a little bit harder.
The full featured single-player can be viewed live at http://html5.florian-rappl.de/SpaceShootSingle/.
The screenshot shows my personal high-score. It should be noted that this is not the best high-score overall - one colleague of mine went up to level 251 and scored over 190000 points.
What will most probably kill a player in such high levels are not asteroids any more, but the (waves of) drones. Fighting around 180 drones at once is close to being impossible without
a lot of health, bombs, ammo, and active shields. Good luck to everyone trying!
This is the second article based on the SpaceShoot game. The first one can be viewed here on CodeProject
at http://www.codeproject.com/Articles/314965/SpaceShoot-A-multiplayer-game-in-HTML5.
Browser issues
According to official sources the game should work fine on all current browsers (IE 9, Chrome 17, Safari 5.1, Opera 11.6 and Firefox 10). However, it seems like all of those browsers did implement the <audio>
tag, but not all of the specified events. Therefore you might experience some issues by using one of those browsers.
The game has been mainly developed by using Opera. A lot of tests have been executed on Google Chrome as well - so those two might be the safest options. If you experience trouble with any current browser (including Opera and Chrome) feel free to report them in the comments as soon as possible.
History
- v1.0.0 | Initial release | 12.02.2012.
- v1.1.0 | Update with social connectors | 14.02.2012.
- v1.1.1 | Update with browser issues | 15.02.2012