Javascript Gauntlet - Entities

Sat, May 11, 2013

Last time we looked at how to parse image files in order to build up our map levels. Now we are going to take a closer look at the entities that make up the level itself.

There are a number of different types of entities in a Gauntlet game, but we are going to concentrate on the primary ones:

  • Treasure
  • Monsters & Generators
  • Players

If you understand how these entities work, then the other types (Doors, Weapons, Exits, and Visual FX) should be easy to understand.

Data Driven Entities

We start by recognising that there are different sub-types of TREASURE, MONSTER and PLAYER, each with a variety of differing attributes, such as health, damage, score, etc that can be declared with a handful of constants.

These constants were tweaked frequently during development to find the right gameplay balance between the 4 players, and against the 5 types of monsters.

Many of the constants are self-explanatory (e.g. health, speed, etc), others will be described in further details in subsequent sections (and a few are omitted for clarity):

PLAYER = {
  WARRIOR:  { health: 500, speed: 200/FPS, damage: 50/FPS, armor: 3, magic: 16, weapon: { speed: 600/FPS, reload: 0.40*FPS, damage: 4, rotate: true,  player: true }, sex: "male",   name: "warrior"  }, // Thor
  VALKYRIE: { health: 500, speed: 220/FPS, damage: 40/FPS, armor: 2, magic: 16, weapon: { speed: 620/FPS, reload: 0.35*FPS, damage: 4, rotate: false, player: true }, sex: "female", name: "valkyrie" }, // Thyra
  WIZARD:   { health: 500, speed: 240/FPS, damage: 30/FPS, armor: 1, magic: 32, weapon: { speed: 640/FPS, reload: 0.30*FPS, damage: 6, rotate: false, player: true }, sex: "male",   name: "wizard"   }, // Merlin
  ELF:      { health: 500, speed: 260/FPS, damage: 20/FPS, armor: 1, magic: 24, weapon: { speed: 660/FPS, reload: 0.25*FPS, damage: 6, rotate: false, player: true }, sex: "male",   name: "elf"      }  // Questor
},
MONSTER = {
  GHOST:  { score:  10, health:  4, speed: 140/FPS, damage: 100/FPS, selfharm: 30/FPS, canbeshot: true,  canbehit: false, invisibility: false,                     travelling: 0.5*FPS, thinking: 0.5*FPS, generator: { health:  8, speed: 2.5*FPS, max: 40, score: 100 }, name: "ghost",  weapon: null                                                                                     },
  DEMON:  { score:  20, health:  4, speed:  80/FPS, damage:  60/FPS, selfharm: 0,      canbeshot: true,  canbehit: true,  invisibility: false,                     travelling: 0.5*FPS, thinking: 0.5*FPS, generator: { health: 16, speed: 3.0*FPS, max: 40, score: 200 }, name: "demon",  weapon: { speed: 240/FPS, reload: 2*FPS, damage: 10, monster: true } },
  GRUNT:  { score:  30, health:  8, speed: 120/FPS, damage:  60/FPS, selfharm: 0,      canbeshot: true,  canbehit: true,  invisibility: false,                     travelling: 0.5*FPS, thinking: 0.5*FPS, generator: { health: 16, speed: 3.5*FPS, max: 40, score: 300 }, name: "grunt",  weapon: null                                                                                     },
  WIZARD: { score:  30, health:  8, speed: 120/FPS, damage:  60/FPS, selfharm: 0,      canbeshot: true,  canbehit: true,  invisibility: { on: 3*FPS, off: 6*FPS }, travelling: 0.5*FPS, thinking: 0.5*FPS, generator: { health: 24, speed: 4.0*FPS, max: 20, score: 400 }, name: "wizard", weapon: null                                                                                     },
  DEATH:  { score: 500, health: 12, speed: 180/FPS, damage: 120/FPS, selfharm: 6/FPS,  canbeshot: false, canbehit: false, invisibility: false,                     travelling: 0.5*FPS, thinking: 0.5*FPS, generator: { health: 16, speed: 5.0*FPS, max: 10, score: 500 }, name: "death",  weapon: null                                                                                     }
},
TREASURE = {
  HEALTH:  { score:  10, health:  50,   sound: 'potion' },
  POISON:  { score:   0, damage:  50,   sound: 'potion' },
  FOOD1:   { score:  10, health:  20,   sound: 'food'   },
  FOOD2:   { score:  10, health:  30,   sound: 'food'   },
  FOOD3:   { score:  10, health:  40,   sound: 'food'   },
  KEY:     { score:  20, key:    true,  sound: 'key'    },
  POTION:  { score:  50, potion: true,  sound: 'potion' },
  GOLD:    { score: 100,                sound: 'gold'   }
},

Each entity class is provided with its sub-type during initialization:

var Monster = Class.create({
  initialize: function(type) {
    this.type = type;
  },

  ...
});

var Treasure = Class.create({
  initialize: function(type) {
    this.type = type;
  },

  ...
});

...

So, for example, constructing entities might look something like this:

var m = new Monster(MONSTER.GHOST),
    p = new Player(PLAYER.WARRIOR),
    t = new Treasure(TREASURE.POTION)

Adding Entities to the Map

However, in practice, we won’t be constructing entities in that manner.

One of our concerns when running HTML5 games in a browser is object allocation and subsequent garbage collection. We want to avoid both! A single level of Gauntlet can generate hundreds, possibly thousands of monsters, depending on how large the level is, and how fast the players can destroy the generators.

In order to minimize object allocation and garbage collection we want to manage a pool of monsters that can be re-used. E.g. once a monster is killed it should go back into the pool to be re-used by the next spawning generator.

This makes entity construction a little more complex, and leads to a Map.add() method that requires further explanation.

The map object itself starts off with a couple of empty data structures ready to be populated:

  this.entities = [];
  this.pool     = { weapons: [], monsters: [], fx: [] }
  • entities: all entities will be stored in this array, active or not.
  • pool: inactive entities will be stored in these arrays ready for re-use

Adding to the map requires coordinates, along with an entity class and an optional pool:

add: function(x, y, klass, pool) {
  var cfunc, entity, args = [].slice.call(arguments, 4);
  if (pool && pool.length) {
    entity = pool.pop();
    entity.initialize.apply(entity, args);
  }
  else {
    cfunc  = klass.bind.apply(klass, [null].concat(args)); // sneaky way to use Function.apply(args) on a constructor
    entity = new cfunc();
    entity.pool = pool;           // entities track which pool they belong to (if any)
    this.entities.push(entity);
  }
  this.occupy(x, y, entity);
  entity.active = true;
  return entity;
},

There is some slightly complex javascript going on in this method, but it really boils down to the following algorithm:

  • If a pool is provided, and its not empty, then pop the next entity out of the pool and re-initialize it
  • Otherwise, construct a new entity
  • In both cases, have the entity occupy() space in the map (more about this in the next article on collision detection)
  • Mark the entity active


To remove an entity from the map (e.g. when a monster dies), requires marking it as inactive, removing it from the map, and if it’s a pooled entity, pushing it back into the pool:

remove: function(obj) {
  obj.active = false;
  this.unoccupy(obj);
  if (obj.pool)
    obj.pool.push(obj);
},


We can also add friendly wrapper methods that all, ultimately, call Map.add():

addGenerator: function(x, y, type)             { return this.add(x, y, Generator, null,               type);             },
addTreasure:  function(x, y, type)             { return this.add(x, y, Treasure,  null,               type);             },
addMonster:   function(x, y, type, generator)  { return this.add(x, y, Monster,   this.pool.monsters, type, generator);  },
addWeapon:    function(x, y, type, dir, owner) { return this.add(x, y, Weapon,    this.pool.weapons,  type, dir, owner); },
...


NOTE: I’m deliberately glossing over the occupy() and unoccupy() methods that actually place the entity at the specified coordinates because I plan to cover map coordinates and grid cells in much greater detail when I talk about collision detection in the next article.


Once entities are added to the map, we can easily update the active ones during our game loop:

  var n, max, entity;
  for(n = 0, max = this.entities.length ; n < max ; n++) {
    entity = this.entities[n];
    if (entity.active && entity.update)
      entity.update();
  }


Treasure

The simplest type of entity we have in the game is the treasure. It is a static entity and so does not actually need to be updated during the game loop. It simply occupies space in the map until a player collides with it, at which point it will be collected().

So, the Treasure entity itself has no behavior, and needs no real code:

var Treasure = Class.create({
  initialize: function(type) {
    this.type = type;
  }
});

Later, when we look closer at collision detection (in the next article) we will see an instance of Treasure being returned by the collision detection system and collected() by the player, where the treasure.type attribute will be used to decide how much health/score/keys/potions to give to the player.

Monster Generators

Ok, Treasure was an easy one, lets get a little more complex. How about monster generators? They need to maintain both their type, and the monster type (mtype) that they will generate, along with their own health and a pending timer that will count down each frame until the next monster is generated:

var Monster = Class.create({
  initialize: function(mtype) {
    this.mtype   = mtype;
    this.type    = mtype.generator;
    this.health  = this.type.health;
    this.pending = 0;
  },

  ...
});

The update() loop for a monster generator is to count down until the next monster spawn, and then try to place a monster into the map on a random side of the generator:

update: function() {
  var pos;
  if (--this.pending <= 0) {
    pos = map.canmove(this, Game.Math.randomInt(0,7), TILE);
    if (pos) {
      map.addMonster(pos.x, pos.y, this.mtype, this);
      this.pending = Game.Math.randomInt(1, this.type.speed);
    }
  }
},

again, I’m going to leave details of the map.canmove() method until we talk about collision detection in the next article.

In addition to the update() method, the generator can be hurt() by a player, and can subsequently die():

hurt: function(damage, by) {
  this.health = Math.max(0, this.health - damage);
  if (this.health === 0)
    this.die(by);
},

die: function(by) {
  publish(EVENT.GENERATOR_DEATH, this, by);
},

When the generator does die() it doesn’t need to know what happens next, it just fires an event. The main game object will handle that event by removing the generator from the map and increasing the score of the player that killed it. We will look more closely at the overall game logic, and event handlers like this one in the final article in this series.

Monsters

The next most complicated entity is the Monster itself (second only to the Player). This is Gauntlet, so the monster AI is very very basic. They generally just head straight towards the player, but we have to be careful about them running into dead ends and flipping back and forth like a crazy ghost, so we let them have just a tiny touch of AI.

At any given time a monster might be

  • normal - they are actively seeking out the player on every game frame
  • thinking - they probably just collided with something, so lets wait a small while before trying to move again
  • travelling - after thinking for a while, lets head in a new direction for a small amount of time before trying to re-aquire the player

So a monster will be initialized with the following state:

var Monster = Class.create({

  initialize: function(type, generator) {
    this.generator  = generator;
    this.type       = type;
    this.dir        = Game.Math.randomInt(0, 7); // start off in random direction, will quickly target player instead
    this.health     = type.health;
    this.thinking   = 0;
    this.travelling = 0;
  },

  ...
});

Before we can look into a monsters update() loop, lets talk a little about how a monster heads towards a player. All entities in Gauntlet are restricted to moving in just 8 DIRECTIONS: UP, DOWN, LEFT, RIGHT and the 4 diagonals in between.

If the player is in a diagonal direction away from the monster then the monster has 3 choices. For example. If the player is UPLEFT of the monster, then the monster can go either:

  • UPLEFT directly (preferred)
  • UP then LEFT
  • LEFT then UP

Obviously the most direct route is preferred, but if that route is not available, for example if blocked by a wall, then the monster can choose between the other 2 options.

This leads to a process that can be broken down into 2 steps:

  • Calculate direction to target
  • Choose a preferred direction to reach that goal

The first step is simple and can be found within the monsters directionTo() method. The 2nd step is to lookup all of the possible PREFERRED_DIRECTIONS for that route, and iterate through each one until we find a path that is not blocked.

You can see this behavior in the loop at the end of the monsters update() method:

update: function() {

  // dont bother trying to update monsters that are far away (a double viewport away)
  if (viewport.outside(this.x - viewport.w, this.y - viewport.h, 2*viewport.w, 2*viewport.h))
    return;

  // dont bother trying to update a monster that is still 'thinking'
  if (this.thinking && --this.thinking)
    return;

  // let travelling monsters travel
  if (this.travelling > 0)
    return this.step(map, player, this.dir, this.type.speed, countdown(this.travelling));

  // otherwise find a new PREFERRED_DIRECTION
  var dirs, n, max;
  dirs = PREFERRED_DIRECTIONS[this.directionTo(player)];
  for(n = 0, max = dirs.length ; n < max ; n++) {
    if (this.step(map, player, dirs[n], this.type.speed, this.type.travelling * n))
      return;
  }

},

The single step() method takes responsibility for moving the monster, it returns true if successful (thus halting the loop above), false otherwise:

step: function(map, player, dir, speed, travelling) {

  // try to move
  var collision = map.trymove(this, dir, speed);

  // if we didn't collide with anything, great! lets travel in that direction for a while
  if (!collision) {
    this.dir        = dir;
    this.thinking   = 0;
    this.travelling = travelling;
    return true;
  }
  // if we collided with something we can interact with (a player) then publish that event
  else if (collision.player) {
    publish(EVENT.MONSTER_COLLIDE, this, collision);
    return true;
  }

  // otherwise, we collided with something static and couldn't move, lets think for a while
  this.thinking   = this.type.thinking;
  this.travelling = 0;
  return false;

},

again, I’m going to leave details of the map.trymove() method until we talk about collision detection in the next article.

In addition to the update() method, a monster, just like a generator, can be hurt() by a player, and can subsequently die():

hurt: function(damage, by) {
  if ((by.weapon && this.type.canbeshot) || (by.player && this.type.canbehit)) {
    this.health = Math.max(0, this.health - damage);
    if (this.health === 0)
      this.die(by);
  }
},

die: function(by) {
  publish(EVENT.MONSTER_DEATH, this, by);
},

And, just like a generator, when a monster does die() it doesn’t need to know what happens next. The main game object handles that event by removing the monster from the map (and putting it back in the pool) and increasing the score of the player that killed it.

Players

The Player entity’s internal state gets initialized when they join() the game:

var Player = Class.create({
  join: function(type) {
    this.type      = type;
    this.dead      = false;
    this.firing    = false;
    this.moving    = {};
    this.reloading = 0;
    this.keys      = 0;
    this.potions   = 0;
    this.score     = 0;
    this.dir       = null;
    this.health    = type.health;
  },
  ...
});

User input is handled by the game’s key mapping (see earlier foundations article) and simply sets, or clears, the players firing, moving, and dir variables:

fire:      function(on) { this.firing       = on;                 },
moveUp:    function(on) { this.moving.up    = on;  this.setDir(); },
moveDown:  function(on) { this.moving.down  = on;  this.setDir(); },
moveLeft:  function(on) { this.moving.left  = on;  this.setDir(); },
moveRight: function(on) { this.moving.right = on;  this.setDir(); },

setDir: function() {
  if (this.moving.up && this.moving.left)
    this.dir = DIR.UPLEFT;
  else if (this.moving.up && this.moving.right)
    this.dir = DIR.UPRIGHT;
  else if (this.moving.down && this.moving.left)
    this.dir = DIR.DOWNLEFT;
  else if (this.moving.down && this.moving.right)
    this.dir = DIR.DOWNRIGHT;
  else if (this.moving.up)
    this.dir = DIR.UP;
  else if (this.moving.down)
    this.dir = DIR.DOWN;
  else if (this.moving.left)
    this.dir = DIR.LEFT;
  else if (this.moving.right)
    this.dir = DIR.RIGHT;
  else
    this.dir = null;
},

The player will always move in the direction requested by the user, however if the player hits a wall we would also like them to be able to ‘slide’ along it without getting hung up. So, in a manner very similar to the Monster’s PREFERRED_DIRECTION, the player has an array of potential SLIDE_DIRECTIONS which we try, in order, until we find the first direction that doesn’t collide with something.

This leaves the Players update() method looking something like this:

update: function(frame, player, map, viewport) {

  // dont bother updating dead players
  if (this.dead)
    return;

  // if we recently fired a weapon, countdown until it has fully reloaded
  this.reloading = countdown(this.reloading);

  // if player is firing (and not reloading) then publish the PLAYER_FIRE event
  if (this.firing) {
    if (!this.reloading) {
      publish(EVENT.PLAYER_FIRE, this);
      this.reloading = this.type.weapon.reload;
    }
    return; // can't fire and move at same time
  }

  // if player is NOT moving then we're done
  if (is.invalid(this.dir))
    return;

  var d, dmax, dir, collision,
      directions = SLIDE_DIRECTIONS[this.dir];

  // otherwise find the first SLIDE_DIRECTION that doesn't collide and move in that direction
  for(d = 0, dmax = directions.length ; d < dmax ; d++) {
    dir = directions[d];
    collision = map.trymove(this, dir, this.type.speed);
    if (collision)
      publish(EVENT.PLAYER_COLLIDE, this, collision); // if we collided with something, publish event and then try next available direction...
    else
      return; // ... otherwise we moved, so we're done trying
  }

},

In addition to the update() method, a player, can heal(), hurt(), die(), exit() a level and collect() treasure. These methods will be called by the main game engine in response to various published events.

heal: function(health) {
  this.health = this.health + health;
  publish(EVENT.PLAYER_HEAL, this, health);
},

hurt: function(damage, by) {
  damage = damage/this.type.armor;
  this.health = Math.max(0, this.health - damage);
  publish(EVENT.PLAYER_HURT, this, damage);
  if (this.health === 0)
    this.die();
},

die: function() {
  this.dead = true;
  publish(EVENT.PLAYER_DEATH, this);
},

collect: function(treasure) {
  this.score = this.score + treasure.type.score;
  if (treasure.type.potion)
    this.potions++;
  else if (treasure.type.key)
    this.keys++;
  else if (treasure.type.health)
    this.heal(treasure.type.health);
  else if (treasure.type.damage)
    this.hurt(treasure.type.damage);
  publish(EVENT.TREASURE_COLLECTED, treasure, this);
},

exit: function(exit) {
  this.health  = this.health + 100;
  publish(EVENT.PLAYER_EXIT, this, exit);
},

Next Time…

The main entities that we covered in this article are

  • Treasure
  • Generators
  • Monsters
  • Players

Other entities include

  • Weapons - fired by the player, and some monsters.
  • Doors - can be opened when players collide with them (if they have a key)
  • Exits - players can collide with exits to complete the level
  • Fx - the explosive visual fx are also entities.

They each follow similar patterns, an initialize() method, an update() method and some specialized methods that are usually called by the main game engine in response to some event.

The code snippets in this article are somewhat simplified versions of the real code. I’ve skipped a little of the low level details for clarity, and haven’t talked at all about how the entities get rendered (fairly standard <canvas> drawImage method calls)

The biggest ommision is exactly how these entities occupy() the map and how they move() within it. This requires a conversation about collision detection…

… which is what we will talk about next time!