Afternoon 4: Health, Score, and Win/Lose Conditions

Our game looks more like a real game now, but there’s still a lot of room for improvement.

Enemy Health

Phaser makes modifying enemy toughness easy for us because it supports health and damage calculation.

Before we could implement health to our enemies, let’s first add a hit animation (finally using the last frame of the sprite sheet):

  setupEnemies: function () {
...
    this.enemyPool.setAll('checkWorldBounds', true)

    // Set the animation for each sprite
    this.enemyPool.forEach(function (enemy) {
      enemy.animations.add('fly', [ 0, 1, 2 ], 20, true);
      enemy.animations.add('hit', [ 3, 1, 3, 2 ], 20, false);
      enemy.events.onAnimationComplete.add( function (e) {
        e.play('fly');
      }, this);
    });

    this.nextEnemyAt = 0;

The new animation is a very short non-looping blinking animation which goes back to the original fly animation once it ends.

Let’s now add the health. Sprites in Phaser have a default health value of 1 but we can override it anytime:

  spawnEnemies: function () { 
    if (this.nextEnemyAt < this.time.now && this.enemyPool.countDead() > 0) {
      this.nextEnemyAt = this.time.now + this.enemyDelay;
      var enemy = this.enemyPool.getFirstExists(false);
      // spawn at a random location top of the screen
      enemy.reset(this.rnd.integerInRange(20, this.game.width - 20), 0);
      enemy.reset(
        this.rnd.integerInRange(20, this.game.width - 20), 0,
        BasicGame.ENEMY_HEALTH
      );
      enemy.body.velocity.y = this.rnd.integerInRange(
        BasicGame.ENEMY_MIN_Y_VELOCITY, BasicGame.ENEMY_MAX_Y_VELOCITY
      );
      enemy.play('fly');
    }
  },

We could have used enemy.health = BasicGame.ENEMY_HEALTH but reset() already has an optional parameter that does the same.

And finally, let’s create a new function to process the damage, centralizing the killing and explosion animation:

  enemyHit: function (bullet, enemy) {
    bullet.kill();
    this.explode(enemy);
    enemy.kill();
    this.damageEnemy(enemy, BasicGame.BULLET_DAMAGE);
  },

  playerHit: function (player, enemy) {
    this.explode(enemy);
    enemy.kill();
    // crashing into an enemy only deals 5 damage
    this.damageEnemy(enemy, BasicGame.CRASH_DAMAGE);
    this.explode(player);
    player.kill();
  },
203   damageEnemy: function (enemy, damage) {
204     enemy.damage(damage);
205     if (enemy.alive) {
206       enemy.play('hit');
207     } else {
208       this.explode(enemy);
209     }
210   },

Using damage() automatically kill()s the sprite once its health is reduced to zero.

Player Score

We don’t need to explain how important it is to display the player’s current score on the screen. Everyone just knows it.

First set the score rewarded on kill:

  setupEnemies: function () {
...
    this.enemyPool.setAll('outOfBoundsKill', true);
    this.enemyPool.setAll('checkWorldBounds', true);
    this.enemyPool.setAll('reward', BasicGame.ENEMY_REWARD, false, false, 0, true);

    // Set the animation for each sprite
    this.enemyPool.forEach(function (enemy) {

We used the full form of the setAll() function. The last four parameters are default, and we only change the last parameter to true which forces the function to set the reward property even though it isn’t there.

Next step is to add the setupText() code for displaying the starting score:

  setupText: function () {
    this.instructions = this.add.text(
      this.game.width / 2, 
      this.game.height - 100, 
      'Use Arrow Keys to Move, Press Z to Fire\n' + 
      'Tapping/clicking does both', 
      { font: '20px monospace', fill: '#fff', align: 'center' }
    );
    this.instructions.anchor.setTo(0.5, 0.5);
    this.instExpire = this.time.now + BasicGame.INSTRUCTION_EXPIRE;

    this.score = 0;
    this.scoreText = this.add.text(
      this.game.width / 2, 30, '' + this.score, 
      { font: '20px monospace', fill: '#fff', align: 'center' }
    );
    this.scoreText.anchor.setTo(0.5, 0.5);
  },

And then let’s add it to our enemy damage/death handler:

  damageEnemy: function (enemy, damage) {
    enemy.damage(damage);
    if (enemy.alive) {
      enemy.play('hit');
    } else {
      this.explode(enemy);
      this.addToScore(enemy.reward);
    }
  },
221   addToScore: function (score) {
222     this.score += score;
223     this.scoreText.text = this.score;
224   },

Player Lives

Sudden death games are cool, but may be “unfun” for others. Most people are used to having lives and retries in their games.

First, let’s create a new sprite group representing our lives at the top right corner of the screen.

  create: function () {
    this.setupBackground();
    this.setupPlayer();
    this.setupEnemies();
    this.setupBullets();
    this.setupExplosions();
    this.setupPlayerIcons();
    this.setupText();

    this.cursors = this.input.keyboard.createCursorKeys();
  },

...
118   setupPlayerIcons: function () {
119     this.lives = this.add.group();
120     // calculate location of first life icon
121     var firstLifeIconX = this.game.width - 10 - (BasicGame.PLAYER_EXTRA_LIVES * 30);
122     for (var i = 0; i < BasicGame.PLAYER_EXTRA_LIVES; i++) {
123       var life = this.lives.create(firstLifeIconX + (30 * i), 30, 'player');
124       life.scale.setTo(0.5, 0.5);
125       life.anchor.setTo(0.5, 0.5);
126     }
127   },

For the life icons, we just used the player’s sprite and scaled it down to half its size by modifying the scale property.

With the life tracking done, let’s add the blinking ghost animation on player death:

    this.player.animations.add('fly', [ 0, 1, 2 ], 20, true);
    this.player.animations.add('ghost', [ 3, 0, 3, 1 ], 20, true);
    this.player.play('fly');

Then let’s modify playerHit() to activate “ghost mode” for 3 seconds and ignore everything around us while we’re a ghost:

  playerHit: function (player, enemy) {
    // check first if this.ghostUntil is not not undefined or null 
    if (this.ghostUntil && this.ghostUntil > this.time.now) {
      return;
    }
    // crashing into an enemy only deals 5 damage
    this.damageEnemy(enemy, BasicGame.CRASH_DAMAGE);
    this.explode(player);
    player.kill();
    var life = this.lives.getFirstAlive();
    if (life !== null) {
      life.kill();
      this.ghostUntil = this.time.now + BasicGame.PLAYER_GHOST_TIME;
      this.player.play('ghost');
    } else {
      this.explode(player);
      player.kill();
    }
  },

And finally, we modify the processDelayedEffects() function to check if the ghost mode has already expired:

  processDelayedEffects: function () {
    if (this.instructions.exists && this.time.now > this.instExpire) {
      this.instructions.destroy();
    }

    if (this.ghostUntil && this.ghostUntil < this.time.now) {
      this.ghostUntil = null;
      this.player.play('fly');
    }
  },

Win/Lose Conditions, Go back to Menu

One of the last things we need to implement is a game ending condition. Currently, our player can die, but there’s no explicit message whether the game is over or not. On the other hand, we also don’t have a “win” condition.

Let’s implement both to wrap up our prototype.

Create a new function to display the end game message:

255   displayEnd: function (win) {
256     // you can't win and lose at the same time
257     if (this.endText && this.endText.exists) {
258       return;
259     }
260 
261     var msg = win ? 'You Win!!!' : 'Game Over!';
262     this.endText = this.add.text( 
263       this.game.width / 2, this.game.height / 2 - 60, msg, 
264       { font: '72px serif', fill: '#fff' }
265     );
266     this.endText.anchor.setTo(0.5, 0);
267 
268     this.showReturn = this.time.now + BasicGame.RETURN_MESSAGE_DELAY;
269   },

Modify the playerHit() function to call the “Game Over!” message:

  playerHit: function (player, enemy) {
...
    } else {
      this.explode(player);
      player.kill();
      this.displayEnd(false);
    }

Do the same to the addToScore() function, but now to destroy all enemies (preventing accidental death and also stopping them from spawning) and display “You Win!!!” message upon reaching 2000 points:

  addToScore: function (score) {
    this.score += score;
    this.scoreText.text = this.score;
    if (this.score >= 2000) {
      this.enemyPool.destroy();
      this.displayEnd(true);
    }
  },

(No need to set 2000 as a constant because it’s only a temporary placeholder. We’ll change this value in the last afternoon chapter.)

Let’s also display a “back to main menu” message a few seconds after the game ends. In processDelayedEffects():

      this.player.play('fly');
    }

    if (this.showReturn && this.time.now > this.showReturn) {
      this.returnText = this.add.text(
        this.game.width / 2, this.game.height / 2 + 20, 
        'Press Z or Tap Game to go back to Main Menu', 
        { font: '16px sans-serif', fill: '#fff'}
      );
      this.returnText.anchor.setTo(0.5, 0.5);
      this.showReturn = false;
    }
  },

Since our main menu button is the same action as firing bullets, we can modify processPlayerInput() function to allow us to quit the game:

    if (this.input.keyboard.isDown(Phaser.Keyboard.Z) ||
        this.input.activePointer.isDown) {
      this.fire();
      if (this.returnText && this.returnText.exists) {
        this.quitGame();
      } else {
        this.fire();
      }
    }
  },

Before going back to the main menu, let’s destroy all objects in the world to allow us to play over and over again:

  quitGame: function (pointer) {

    //  Here you should destroy anything you no longer need.
    //  Stop music, delete sprites, purge caches, free resources, all that good stuff.
    this.sea.destroy();
    this.player.destroy();
    this.enemyPool.destroy();
    this.bulletPool.destroy();
    this.explosionPool.destroy();
    this.instructions.destroy();
    this.scoreText.destroy();
    this.endText.destroy();
    this.returnText.destroy();
    //  Then let's go back to the main menu.
    this.state.start('MainMenu');

  }

Going back to the main menu will display a black screen with text. This is because we skipped loading the title page image in preloader.js. To properly display the main menu, let’s temporarily add the pre-loading in mainMenu.js:

BasicGame.MainMenu.prototype = {
  preload: function () {
    this.load.image('titlepage', 'assets/titlepage.png');
  },

  create: function () {

Enjoy playing your prototype game!

Game Over screen
Game Over screen
Win screen
Win screen
pressing Z returns you to the Main Menu
pressing Z returns you to the Main Menu