JavaScript Game Tutorial - Space Invaders Part 4 - Collision Detection
This is the fourth part in a series about creating a Space Invaders clone in JavaScript. It is highly recommended to start from the beginning as each part builds directly upon the previous.
Space Invaders Tutorial Series | |
---|---|
Part 0 | Beginner JavaScript Game Tutorial For Professional Use |
Part 1 | Math Classes and Game Entity Structure |
Part 2 | User Input |
Part 3 | Enemy Behavior |
Part 4 | Collision Detection and Projectiles |
Part 5 | Sprites and User Interface |
Part 6 | Optimization |
Part 7 | 3D Renderer |
Part 8 | Events and Audio |
Introduction
In Part 3, we implemented the enemy movement and behavior and now we are going to bring it all together to finally make this a playable game. We will add the Projectile Entity, implement collision detection, scoring and game over. With these final components, the player will be able to kill enemies and be killed.
Goals
The goals for this part are:
- Create the Projectile entity.
- Update the Renderer to draw the projectiles.
- Update the Player and Enemy fire functions.
- Update Physics with collision detection.
- Update Game to delete dead entities, keep score and check for game over.
Projectile
There are two types of projectiles: player and enemy. Enemy projectiles only hurt the player and player projectiles only hurt the enemies. This is determined by the Projectile type property.
Projectile | ||
---|---|---|
Property | Type | Description |
type | String | either "player" or "enemy" |
//
// Projectile
//
function Projectile(position, speed, direction, type) {
Entity.call(this, position, speed, direction);
this.width = 1;
this.height = 5;
this.type = type;
}
Projectile.prototype = Object.create(Entity.prototype);
Renderer
Update the Renderer to draw the projectiles, using two colors to distinguish the enemy and player projectile types.
_projectileColors = {"player": "rgb(196, 208, 106)",
"enemy": "rgb(96, 195, 96)"};
function _render(dt) {
_context.fillStyle = "black";
_context.fillRect(0, 0, _canvas.width, _canvas.height);
var i,
entity,
entities = game.entities();
for( i=entities.length-1; i>=0; i-- ) {
entity = entities[i];
if( entity instanceof Enemy ) {
_drawRectangle(_enemyColors[entity.rank], entity);
}
else if( entity instanceof Player ) {
_drawRectangle("rgb(255, 255, 0)", entity);
}
else if( entity instanceof Projectile ) {
_drawRectangle(_projectileColors[entity.type], entity);
}
}
}
Game
The Game object needs to keep track of the Projectile entities in the same way it tracks Enemy entities. Add a new property _projectiles to the Game object and initialize it to an empty array in _start(). Update the addEntity() and removeEntities() functions to keep track of projectiles. Finally, expose the projectiles property in the return statement.
Game | ||
---|---|---|
Property | Type | Description |
_projectiles | Array | all the Projectile entities |
// Add to the _start function
_projectiles = [];
function _addEntity(entity) {
_entities.push(entity);
if( entity instanceof Player ) {
_player = entity;
}
if( entity instanceof Enemy ) {
_enemies.push(entity);
}
if( entity instanceof Projectile ) {
_projectiles.push(entity);
}
}
function _removeEntities(entities) {
if( !entities ) return;
function isNotInEntities(item) { return !entities.includes(item); }
_entities = _entities.filter(isNotInEntities);
_enemies = _enemies.filter(isNotInEntities);
_projectiles = _projectiles.filter(isNotInEntities);
if(entities.includes(_player)) {
_player = undefined;
}
}
// Add to the return statement
projectiles: function () { return _projectiles; }
Now we have the Projectile Entity fully integrated and ready to use. So let's implement the fire commands of both the Player and Entity.
Player
The player can only have one projectile on screen at a time, so it has to check for the existence of a player projectile before firing. If there are no player projectiles, it will create one.
Player.prototype.fire = function () {
function isPlayerType(proj) {
return (proj.type === "player");
}
var playerProjectileCount = game.projectiles().filter(isPlayerType).length;
if( playerProjectileCount === 0 ) {
game.addEntity( new Projectile( this.position,
180,
new Vector2d(0, -1),
"player" ));
}
};
Enemy
The enemy has no restrictions on its projectiles, so its fire function just creates a projectile.
Enemy.prototype.fire = function (position) {
game.addEntity( new Projectile( position,
60,
new Vector2d(0, 1),
"enemy") );
};
Physics
Collision Detection is a major part of a game engine. Faulty collisions are one of the most noticeable bugs that a player will see. As you move on to make more complex games, the physics and collision detection systems are the first things you will have to improve. A very general overview of a collision system is that first you determine a list of items that need to be checked if they have collided. Then you actually check for the collisions. Lastly you resolve the collisions. Each of these parts can become very complex, especially in 3D games.
Luckily, we only need a simple system for a Space Invaders clone. We will be checking for a few different collision scenarios:
- Enemy Projectile hits Player
- Player Projectile hits Enemy
- Enemy hits Player
- Enemy hits bottom of game field
- Projectile leaves game field
Resolving the collisions is as simple as damaging the entities involved.
function _update(dt) {
var i, j,
entities = game.entities(),
enemies = game.enemies(),
projectiles = game.projectiles(),
player = game.player();
for( i=entities.length-1; i>=0; i-- ) {
var e = entities[i];
velocity = vectorScalarMultiply( e.direction,
e.speed );
e.position = vectorAdd( e.position,
vectorScalarMultiply( velocity,
dt ) );
}
// Collision Detection
var collisionPairs = [];
// Enemies vs Player
for( i=enemies.length-1; i>=0; i-- ) {
collisionPairs.push( { entity0: enemies[i],
entity1: player } );
}
// Projectiles vs other Entities
for( i=projectiles.length-1; i>=0; i-- ) {
// Enemy Projectiles vs Player
if( projectiles[i].type === "enemy") {
collisionPairs.push( { entity0: projectiles[i],
entity1: player } );
}
// Player Projectiles vs Enemies
if( projectiles[i].type === "player" ) {
for( j=enemies.length-1; j>=0; j-- ) {
collisionPairs.push( { entity0: projectiles[i],
entity1: enemies[j] } );
}
}
}
// Collision Check
for( i=collisionPairs.length-1; i>=0; i-- ) {
var e0 = collisionPairs[i].entity0;
var e1 = collisionPairs[i].entity1;
if( e0 && e1 && e0.collisionRect().intersects(e1.collisionRect()) ) {
// Resolve Collision
e0.hp -= 1;
e1.hp -= 1;
}
}
// Enemy vs floor (special case)
if( game.enemiesRect() && player &&
game.enemiesRect().bottom() > player.collisionRect().bottom() ) {
game.setGameOver();
}
// Projectile leaves game field (special case)
for( i=projectiles.length-1; i>=0; i-- ) {
var proj = projectiles[i];
if( !game.gameFieldRect().intersects(proj.collisionRect()) ) {
proj.hp -= 1;
}
}
}
With collision detection in place, we just have to tie everything together in the Game object's update() function.
Game
Now that entities can actually be killed with collisions, the Game object has to remove the dead entities, track the player's remaining lives, keep score and determine game over. We need a few new properties for this.
Game | ||
---|---|---|
Property | Type | Description |
_livesRemaining | Number | the remaining lives of the player |
_gameOver | Boolean | true if the game over condition has been met |
_score | Number | current score |
_highScores | Array | array of high scores |
Function | Return Type | Description |
_addScore( score ) | none | adds score to the list of high scores |
_setGameOver() | none | set the game over state and record the high score |
Add the new properties to Game and initialize them in _start(). The high scores are stored in localStorage if available. This saves the scores between page loads. JSON.parse() throws an exception if localStorage.invadersScores doesn't exist or is otherwise corrupted. In that case, we just create an empty high scores array.
_livesRemaining = 2;
_gameOver = false;
_score = 0;
_highScores = [];
if (typeof(Storage) !== "undefined") {
try {
_highScores = JSON.parse(localStorage.invadersScores);
}
catch(e) {
_highScores = [];
}
}
Create the _addScore() function to insert a score into the list of high scores. The list is sorted and only the top 10 scores are saved.
function _addScore(score) {
_highScores.push(score);
_highScores.sort(function(a, b){return b-a});
_highScores = _highScores.slice(0, 10);
if (typeof(Storage) !== "undefined") {
localStorage.invadersScores = JSON.stringify(_highScores);
}
}
Create the _setGameOver() function that simply sets the _gameOver variable and records the current score.
function _setGameOver() {
_gameOver = true;
_addScore(Math.round(game.score()));
}
The update function has to clean up the entities and perform some bookkeeping. If a projectile dies, they are removed. If an enemy dies, they are removed and the score increases based on the enemy rank. If the player dies, they lose a life. If the remaining lives is less than zero, the game is over. When the game is over, we interrupt the entire game loop, which literally stops the game.
function _update(time) {
var i, j,
dt = Math.min((time - _lastFrameTime) / 1000, 3/60);
_lastFrameTime = time;
if( _gameOver ) {
_started = false;
return;
}
// Update Physics
physics.update(dt);
// Calculate the bounding rectangle around the enemies
_enemiesRect = _enemies.reduce(function(rect, e) {
return rectUnion(rect, e.collisionRect());
},
undefined);
// Update Entities
for( i=_entities.length-1; i>=0; i-- ) {
_entities[i].update(dt);
}
// Delete dead objects.
var removeEntities = [];
for( i=_entities.length-1; i>=0; i-- ) {
var e = _entities[i];
if( e.hp <= 0 ) {
removeEntities.push(e);
if( e instanceof Enemy ) {
_score += e.rank + 1;
}
else if( e instanceof Player ) {
_livesRemaining--;
this.addEntity( new Player( new Vector2d(100, 175), 90, new Vector2d(0, 0) ));
}
}
}
_removeEntities(removeEntities);
// Update Enemy Speed
var speed = _enemySpeed + (_enemySpeed*(1-(_enemies.length/50)));
for( i=_enemies.length-1; i>=0; i-- ) {
_enemies[i].speed = speed;
}
// Create new Enemies if there are 0
if( _enemies.length === 0 ) {
for( i=0; i<10; i++) {
for( j=0; j<5; j++) {
var dropTarget = 10+j*20,
position = new Vector2d(50+i*20, dropTarget-100),
direction = new Vector2d(1, 0),
rank = 4-j,
enemy = new Enemy(position,
_enemySpeed,
direction,
rank);
enemy.dropTarget = dropTarget;
enemy.firePercent = _enemyFirePercent;
enemy.dropAmount = _enemyDropAmount;
this.addEntity( enemy );
}
}
_enemySpeed += 5;
_enemyFirePercent += 5;
_enemyDropAmount += 1;
}
// Check for Game Over
if( _livesRemaining < 0 && !_gameOver ) {
_setGameOver();
}
// Render the frame
renderer.render(dt);
window.requestAnimationFrame(this.update.bind(this));
}
// Expose in the return statement
score: function () { return _score; },
highScores: function () { return _highScores; },
livesRemaining: function () { return _livesRemaining; },
gameOver: function () { return _gameOver; },
setGameOver: _setGameOver
Conclusion
As you can see, the game is actually playable! We've built up to this point one piece at a time, but now that it is all working, I think you can see how important having the proper structure has been to making this simple game. Each major component operates independently of the others, allowing great flexibility and later code reuse.
The road map for the rest of the series is:
- Part 5. Focus on the Renderer, adding Sprites and all the UI elements, as well as general polish to the game.
- Part 6. Optimize the memory usage to ensure a stable frame rate.
- Part 7. Switch to a 3D Renderer.
- Part 8. Add an Event System and Audio.
I have packaged the code for the full tutorial series for anyone interested in downloading it.