Javascript Gauntlet - Entities
Sat, May 11, 2013Last 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()
andunoccupy()
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!
Related Links
- play the game
- view the source code
- read more about game foundations
- read more about game level maps
- read more about game entities
- read more about game collision detection
- read more about game logic