JavaScript Game Tutorial - Space Invaders Part 3 - Enemy Behavior
This is the third 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 2, we implemented the user input system and got the player moving on screen. In this part, we will implement the Enemy behavior.
Goals
The goals for this part are:
- Game object updates to create the enemies in a grid, manage an enemy bounding box and enemy speed.
- Enemy updates to change direction and decide when to fire weapon.
Game
The Game object controls the waves of enemies to make them more difficult over time. We add a few properties to Game to deal with this. We also need to keep track of the bounding box around the enemies.
Game | ||
---|---|---|
Property | Type | Description |
_lastFrameTime | Number | the time at the previous frame |
_enemiesRect | Rectangle | rectangle that surrounds all existing enemies |
_enemySpeed | Number | base movement speed of all enemies |
_enemyFirePercent | Number | percent that the enemy will fire weapon |
_enemyDropAmount | Number | distance (in world units) that enemies drop when they hit an edge |
Function | Return Type | Description |
update( time ) | none | create enemies in grid, update enemiesRect |
Add these properties to the Game object and set their values in the _start() function. Remove the enemy creation from _start() as we are moving the enemy creation code into _update().
function _start() {
_lastFrameTime = 0;
_entities = [];
_enemies = [];
_gameFieldRect = new Rectangle(0, 0, 300, 180);
_enemiesRect = new Rectangle(0, 0, 0, 0);
_enemySpeed = 10;
_enemyFirePercent = 10;
_enemyDropAmount = 1;
this.addEntity( new Player( new Vector2d(100, 175), 90, new Vector2d(0, 0)) );
if( !_started ) {
window.requestAnimationFrame(this.update.bind(this));
_started = true;
}
}
The rest of the changes are in the _update( time ) function. Once you have a lot of things on screen, you can start to notice frame rate issues. So I've updated from a fixed time step to a calculated, but limited, one. This allows the occasional frame drop without slowing down the game, but a larger drop will cap at 3/60 of a second, so the game clock will actually slow down. This should rarely happen, but it's nice to smooth out frame drops like this.
function _update( time ) {
var i, j,
dt = Math.min((time - _lastFrameTime) / 1000, 3/60);
_lastFrameTime = time;
// Update Physics
physics.update(dt);
To calculate the enemiesRect, we use the rectUnion function over all the existing enemy collision rectangles. This will give us one giant rectangle that encompasses every enemy on screen. The Enemy will use this information to determine when the whole group has hit the edge of the screen.
// 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);
}
We set the speed of the enemies so that they get faster as the enemies die. When we add the projectiles in the next part, we will be removing the dead enemies and the remaining enemies will naturally get faster as _enemies.length gets smaller.
// Update Enemy Speed
var speed = _enemySpeed + (_enemySpeed*(1-(_enemies.length/50)));
for( i=_enemies.length-1; i>=0; i-- ) {
_enemies[i].speed = speed;
}
The creation of the enemies happens when there are no enemies left. We calculate a grid placement. We set the position 100 above the dropTarget so the enemies will descend from the top of the screen. By increasing _enemySpeed, _enemyFirePercent and _enemyDropAmount, the game gets increasingly more difficult with each round of enemies. The amount you raise these will dictate how fast the game increases in difficulty.
// Create the grid of 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;
}
// Render the frame
renderer.render(dt);
window.requestAnimationFrame(this.update.bind(this));
}
Finally, expose enemiesRect in the return statement:
return {
start: _start,
update: _update,
addEntity: _addEntity,
entities: function () { return _entities; },
enemies: function () { return _enemies; },
player: function () { return _player; },
gameFieldRect: function () { return _gameFieldRect; },
enemiesRect: function () { return _enemiesRect; }
};
Enemy
The enemies move as a group because each individual enemy is performing the same movement calculation. It would be just as easy at this point to have different enemies have different movement patterns. In this way you could implement Galaxian instead of Space Invaders.
Enemy | ||
---|---|---|
Property | Type | Description |
dropTarget | Number | the y-value of vertical position we should be |
dropAmount | Number | how much we drop each time an edge is hit |
timer | Number | time elapsed |
firePercent | Number | percent chance to fire weapon |
fireWait | Number | seconds to wait between chance to fire |
Function | Return Type | Description |
update( dt ) | none | check for the dropTarget, hitting edges, firing weapon |
fire(position) | none | fire weapon with projectile starting at position |
Add these new variables to the Enemy constructor:
function Enemy(position, speed, direction, rank) {
Entity.call(this, position, speed, direction);
this.width = 13;
this.height = 10;
this.rank = rank;
this.dropTarget = 0;
this.dropAmount = 1;
this.timer = 0;
this.firePercent = 10;
this.fireWait = Math.random() * 5;
}
The big changes occur in update( dt ). First we determine what direction to move. If the enemiesRect is within a margin of either edge of the gameFieldRect, we set a new dropTarget. If the current position is above the dropTarget, we set the direction downwards. Once we have hit the dropTarget, we set the direction either left or right depending on which edge of the screen the enemiesRect hit.
Enemy.prototype.update = function (dt) {
// Edge collision
var enemiesLeft = game.enemiesRect().left(),
enemiesRight = game.enemiesRect().right(),
edgeMargin = 5,
gameLeftEdge = game.gameFieldRect().left() + edgeMargin,
gameRightEdge = game.gameFieldRect().right() - edgeMargin;
Entity.prototype.update.call(this, dt);
// Drop if the enemiesRect hits an edge margin
if( (this.direction.x < 0 && enemiesLeft < gameLeftEdge) ||
(this.direction.x > 0 && enemiesRight > gameRightEdge) ) {
this.dropTarget += this.dropAmount;
}
// Determine Direction
if( this.position.y < this.dropTarget ) {
this.direction = new Vector2d(0, 1);
}
else if( this.direction.y > 0 ) {
this.direction = (enemiesRight > gameRightEdge) ?
new Vector2d(-1, 0) :
new Vector2d(1, 0);
}
We don't want the firing to be a predictable pattern, so to make it interesting, we introduce two random factors: fireWait and firePercent. First we have to wait between shots. Once the wait has been satisfied, we will only fire a certain percentage of the time. In addition to these random factors, we check that there isn't another enemy below. Only the bottom enemy in a column can fire. That's what existsUnderneath() is checking. Much like in the Player object, we have to wait to implement the firing until we add projectiles in the next part of this series.
// Determine Firing Weapon
var p = vectorAdd(this.position, new Vector2d(0, 5));
function existsUnderneath(e) {
var rect = e.collisionRect();
return p.y <= rect.top() &&
rect.left() <= p.x && p.x <= rect.right();
}
this.timer += dt;
if( this.timer > this.fireWait ) {
this.timer = 0;
this.fireWait = 1 + Math.random() * 4;
if( randomInt(100) < this.firePercent &&
!game.enemies().find(existsUnderneath) ) {
this.fire(p);
}
}
};
Enemy.prototype.fire = function (position) {
console.log("Fire to be implemented");
};
Conclusion
In this part we updated the Game and Enemy objects to create the enemy behavior. You can see the game beginning to take form now, though there isn't anything the player can actually do. With the addition of projectiles and collision detection, the whole game will come together.
The road map for the rest of the series is:
- Part 4. Projectiles and collision detection. The game is finally playable.
- Part 5. We will finish the game off with a proper UI and use of sprites.
- 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.