Rot.js tutorial, part 3

From RogueBasin
(Difference between revisions)
Jump to: navigation, search
(Created page with "This is the third part of a rot.js tutorial. '''FIXME THIS IS A WORK IN PROGRESS, NOT FINISHED YET!''' == The Player Character == Time to make some interesting interact...")
 
(Moved lines to match JSFiddle, and also because I confused them while doing the tutorial and only wrote passableCallback. Having them this way around makes it much clearer that there are two separate callbacks at a quick glance.)
 
(12 intermediate revisions by 2 users not shown)
Line 1: Line 1:
 
This is the third part of a [[rot.js tutorial]].
 
This is the third part of a [[rot.js tutorial]].
  
'''FIXME THIS IS A WORK IN PROGRESS, NOT FINISHED YET!'''
+
== Looking inside the box ==
  
== The Player Character ==
+
The game generates several boxes, but so far, none of them contains the prized ananas. Let us store the ananas in the first box generated:
 
+
Time to make some interesting interactive shinies! First, the player needs a decent representation. It would be sufficient to use a plain JS object to represent the player, but it is generally more robust to define the player via its constructor function and instantialize it.
+
 
+
By this time, you probably got used to the fact that some variable names start with an underscore. This is a relatively common technique of marking them ''private''. JavaScript does not offer true private variables, so this underscore-based nomenclature is just our useful way of marking stuff as "internal".
+
+
We would like to place the player on some spare floor tile: let's use exactly the same technique we used in Part 1 of this tutorial to place the boxes: just pick one free location from our list.
+
  
 
<div style="padding:5px; background-color:#eee; margin-bottom:2em;">
 
<div style="padding:5px; background-color:#eee; margin-bottom:2em;">
 
<syntaxhighlight lang="javascript">
 
<syntaxhighlight lang="javascript">
var Player = function(x, y) {
+
Game.ananas = null;
    this._x = x;
+
    this._y = y;
+
    this._draw();
+
}
+
  
Player.prototype._draw = function() {
+
Game._generateBoxes = function(freeCells) {
     Game.display.draw(this._x, this._y, "@", "#ff0");
+
     for (var i=0;i<10;i++) {
 +
        /* ...previous stuff... */
 +
        if (!i) { this.ananas = key; } /* first box contains an ananas */
 +
    }
 
}
 
}
 +
</syntaxhighlight>
 +
</div>
  
Game.player = null;
+
Apart from moving, there is one more interaction a player must perform: looking into boxes. We will allow both Enter (keyCode 13) and Spacebar (keyCode 32) for this action:
  
Game._generateMap = function() {
+
<div style="padding:5px; background-color:#eee; margin-bottom:2em;">
    /* ...previous stuff... */
+
<syntaxhighlight lang="javascript">
    this._createPlayer(freeCells);
+
Player.prototype.handleEvent = function(e) {
};
+
     var code = e.keyCode;
 
+
     if (code == 13 || code == 32) {
Game._createPlayer = function(freeCells) {
+
        this._checkBox();
     var index = Math.floor(ROT.RNG.getUniform() * freeCells.length);
+
        return;
     var key = freeCells.splice(index, 1)[0];
+
    }
    var parts = key.split(",");
+
}
    var x = parseInt(parts[0]);
+
    var y = parseInt(parts[1]);
+
    this.player = new Player(x, y);
+
};
+
 
</syntaxhighlight>
 
</syntaxhighlight>
 
</div>
 
</div>
  
== Preparing the game turn engine ==
+
Opening a box to verify its contents is as simple as comparing the player's current position with our list of boxes and the stored ananas position:
 
+
There will be two entities taking turns in our game: the Player Character and Pedro (The Enemy). To make things simple, these two will have the same speed, alternating their turns evenly. But even in this simple case, we can use the <code>ROT.Engine</code> timing framework to our advantage.
+
 
+
How does this work? After creating an instance of <code>ROT.Engine</code>, we feed it with all available ''actors''. The engine will then automatically take care of proper turn scheduling and letting these actors perform their actions.
+
 
+
It is very important to embrace the fact that everything is asynchronous in the world of client-side JavaScript: there are basically no blocking calls. This eliminates the possibility of having a simple ''while'' loop as our main timing/scheduling instrument. Fortunately, the <code>ROT.Engine</code> is well prepared for this.
+
 
+
Creating the engine is just a matter of adding a few lines to our code:  
+
  
 
<div style="padding:5px; background-color:#eee; margin-bottom:2em;">
 
<div style="padding:5px; background-color:#eee; margin-bottom:2em;">
 
<syntaxhighlight lang="javascript">
 
<syntaxhighlight lang="javascript">
Game.engine = null;
+
Player.prototype._checkBox = function() {
 
+
     var key = this._x + "," + this._y;
Game.init = function() {
+
    if (Game.map[key] != "*") {
     this.engine = new ROT.Engine();
+
        alert("There is no box here!");
     this.engine.addActor(this.player);
+
     } else if (key == Game.ananas) {
     this.engine();
+
        alert("Hooray! You found an ananas and won this game.");
 +
        Game.engine.lock();
 +
        window.removeEventListener("keydown", this);
 +
     } else {
 +
        alert("This box is empty :-(");
 +
    }
 
}
 
}
 
</syntaxhighlight>
 
</syntaxhighlight>
 
</div>
 
</div>
  
== Interaction between actors and the engine ==
+
== Pedro, the angry owner ==
  
There is a tight symbiotic relationship between the engine and its actors. When running, the engine repeatedly picks a proper actor from its queue (based on actor's speed) and calls the actor's <code>act()</code> method. Actors are allowed to interrupt this loop (when waiting asynchronously, for example) by calling <code>ROT.Engine::lock</code> and resume it (<code>ROT.Engine::unlock</code>).
+
The game is now winnable! Let's add a villain as a second actor. We will place him using the same algorithm we used previously. To do this, let's refactor the original <code>_createPlayer</code> method into a more useful parametrized ''factory'' <code>_createBeing</code> by passing a constructor function as an argument:
 
+
It is possible to have multiple lock levels (the lock is recursive); this allows for complex chaining of asynchronous calls. Fortunately, this won't be needed in our simple game.
+
 
+
So, what is an actor? Any JS object with methods '''<code>act</code>''' and '''<code>getSpeed</code>'''.
+
  
 
<div style="padding:5px; background-color:#eee; margin-bottom:2em;">
 
<div style="padding:5px; background-color:#eee; margin-bottom:2em;">
 
<syntaxhighlight lang="javascript">
 
<syntaxhighlight lang="javascript">
Player.prototype.getSpeed = function() {
+
var Pedro = function(x, y) {
     return 100;
+
     this._x = x;
 +
    this._y = y;
 +
    this._draw();
 
}
 
}
 
      
 
      
Player.prototype.act = function() {
+
Pedro.prototype._draw = function() {
     Game.engine.lock();
+
     Game.display.draw(this._x, this._y, "P", "red");
    /* wait for user input; do stuff when user hits a key */
+
    window.addEventListener("keydown", this);
+
 
}
 
}
  
Player.prototype.handleEvent = function(e) {
+
Game._createBeing = function(what, freeCells) {
     /* process user input */
+
     var index = Math.floor(ROT.RNG.getUniform() * freeCells.length);
 +
    var key = freeCells.splice(index, 1)[0];
 +
    var parts = key.split(",");
 +
    var x = parseInt(parts[0]);
 +
    var y = parseInt(parts[1]);
 +
    return new what(x, y);
 
}
 
}
</syntaxhighlight>
 
</div>
 
  
We are using somewhat uncommon (but very useful!) technique of assigning event handlers: we pass a JS object as a second argument to the <code>addEventListener</code> call. Such object (<code>this</code>, in this case) must have the <code>handleEvent</code> method, which will be called once the event ("keydown") occurs.
+
Game._generateMap = function() {
 +
    /* ...previous stuff... */
 +
       
 +
    this.player = this._createBeing(Player, freeCells);
 +
    this.pedro = this._createBeing(Pedro, freeCells);
 +
}
  
== Working with the keyboard and moving the player around ==
+
Game.init = function() {
 +
    /* ...previous stuff... */
  
There is one last bit remaining to implement: detect the pressed key, decide whether it is valid and move the player accordingly.
+
    scheduler.add(this.player, true);
 
+
    scheduler.add(this.pedro, true);
Our event handler (<code>handleEvent</code>) gets executed with one argument: the Event object. Its <code>keyCode</code> property is a number code of the key being pressed. Let's create a mapping of allowed key codes (this code sample uses numpad keys, but it is trivial to extend it to other layouts as well):
+
}
 
+
<div style="padding:5px; background-color:#eee; margin-bottom:2em;">
+
<syntaxhighlight lang="javascript">
+
var keyMap = {};
+
keyMap[38] = 0;
+
keyMap[33] = 1;
+
keyMap[39] = 2;
+
keyMap[34] = 3;
+
keyMap[40] = 4;
+
keyMap[35] = 5;
+
keyMap[37] = 6;
+
keyMap[36] = 7;
+
 
</syntaxhighlight>
 
</syntaxhighlight>
 
</div>
 
</div>
  
Numeric values are not chosen randomly: they correspond to directional constants in <code>rot.js</code> (8-topology, clockwise, starting in top-left - the same as CSS does).
+
This might be confusing to some, but passing functions around (as function arguments, for instance) is very common in JavaScript.
  
We need to perform a two-step validation of user input:
+
== Pathfinding-based AI ==
  
# If the key code is not present in <code>keyMap</code>, the user pressed a key which we cannot handle
+
Pedro is missing its <code>act()</code> method so far. We are going to use one of rot.js's pathfinding functions to implement Pedro's behavior: <code>ROT.Path.AStar</code> (the A* algorithm). Some rudimentary scaffolding is necessary:
# If the key code '''is''' present, we need to check whether the PC can move in that direction
+
  
To convert a directional constant (0..7) to a map coordinates, we can use the <code>ROT.DIRS</code> set of topological diffs:
+
# The player must have public methods to read its position,
 +
# We need a ''passableCallback'' function which tells the pathfinder what areas are passable,
 +
# We need a ''pathCallback'' function, which will be called from within the pathfinder (to notify us about the shortest path found).
 +
 
 +
Moreover, to make Pedro somewhat weaker than player, we will use the pathfinder only in 4-topology.
  
 
<div style="padding:5px; background-color:#eee; margin-bottom:2em;">
 
<div style="padding:5px; background-color:#eee; margin-bottom:2em;">
 
<syntaxhighlight lang="javascript">
 
<syntaxhighlight lang="javascript">
Player.prototype.handleEvent = function(e) {
+
Player.prototype.getX = function() { return this._x; }
    if (!(e.keyCode in direction)) { return; }
+
  
    var direction = keyMap[e.keyCode];
+
Player.prototype.getY = function() { return this._y; }
    var diff = ROT.DIRS[8][direction];
+
    var newX = this._x + diff[0];
+
    var newY = this._y + diff[1];
+
  
     var newKey = newX + "," + newY;
+
Pedro.prototype.act = function() {
     if (!(newKey in Game.map)) { return; } /* cannot move in this direction */
+
     var x = Game.player.getX();
 +
     var y = Game.player.getY();
 +
    var passableCallback = function(x, y) {
 +
        return (x+","+y in Game.map);
 +
    }
 +
    var astar = new ROT.Path.AStar(x, y, passableCallback, {topology:4});
 +
 
 +
    var path = [];
 +
    var pathCallback = function(x, y) {
 +
        path.push([x, y]);
 +
    }
 +
    astar.compute(this._x, this._y, pathCallback);
 
}
 
}
 
</syntaxhighlight>
 
</syntaxhighlight>
 
</div>
 
</div>
  
The actual move is performed in two steps - redrawing the old position and redrawing the new position. After that, we remove our keyboard listener (the turn has ended!) and - '''importantly''' - resume the game engine (<code>unlock()</code>).
+
We now have the shortest path between Pedro and the player, stored in the <code>path</code> variable. Note that Pedro's current position is also part of the path; that's why we first discard the first item of our path. If the resulting path is only one-cell long, Pedro is standing close to the player and the game is over (player lost). Otherwise, we apply the same movement logic we used for the player in Part 2 of this tutorial.
  
 
<div style="padding:5px; background-color:#eee; margin-bottom:2em;">
 
<div style="padding:5px; background-color:#eee; margin-bottom:2em;">
 
<syntaxhighlight lang="javascript">
 
<syntaxhighlight lang="javascript">
Player.prototype.handleEvent = function(e) {
+
Pedro.prototype.act = function() {
 
     /* ...previous stuff... */
 
     /* ...previous stuff... */
  
     Game.display.draw(this._x, this._y, Game.map[this._x+","+this._y]);
+
     path.shift(); /* remove Pedro's position */
    this._x = newX;
+
    if (path.length == 1) {
    this._y = newY;
+
        Game.engine.lock();
    this._draw();
+
        alert("Game over - you were captured by Pedro!");
     window.removeEventListener("keydown", this);
+
    } else {
    Game.engine.unlock();
+
        x = path[0][0];
 +
        y = path[0][1];
 +
        Game.display.draw(this._x, this._y, Game.map[this._x+","+this._y]);
 +
        this._x = x;
 +
        this._y = y;
 +
        this._draw();
 +
     }
 +
 
 
}
 
}
 
</syntaxhighlight>
 
</syntaxhighlight>
 
</div>
 
</div>
 +
 +
Ta-dah! The game is complete now; it is possible to win and lose. Some considerations for possible further improvements:
 +
 +
* Player can crash the game by moving onto Pedro's cell. Not only this is currently allowed, but it also disrupts Pedro's pathfinding (which expects the path to be at least two cells long).
 +
* The <code>Game.map</code> structure should probably store positions of beings (player, Pedro) as well.
 +
* It would be comfortable for users to increase the set of allowed navigation keys (number keys, vi keys).
 +
* When a box is inspected, its appearance may change (to make it easier for player to distinguish between visited and unvisited boxes).
  
 
And that's all for part 3. The whole working code is available at [http://jsfiddle.net/rotjs/qRnFY/ jsfiddle.net].
 
And that's all for part 3. The whole working code is available at [http://jsfiddle.net/rotjs/qRnFY/ jsfiddle.net].
 +
 +
[[Category:Developing]]

Latest revision as of 08:41, 16 April 2013

This is the third part of a rot.js tutorial.

[edit] Looking inside the box

The game generates several boxes, but so far, none of them contains the prized ananas. Let us store the ananas in the first box generated:

Game.ananas = null;
 
Game._generateBoxes = function(freeCells) {
    for (var i=0;i<10;i++) {
        /* ...previous stuff... */
        if (!i) { this.ananas = key; } /* first box contains an ananas */
    }
}

Apart from moving, there is one more interaction a player must perform: looking into boxes. We will allow both Enter (keyCode 13) and Spacebar (keyCode 32) for this action:

Player.prototype.handleEvent = function(e) {
    var code = e.keyCode;
    if (code == 13 || code == 32) {
        this._checkBox();
        return;
    }
}

Opening a box to verify its contents is as simple as comparing the player's current position with our list of boxes and the stored ananas position:

Player.prototype._checkBox = function() {
    var key = this._x + "," + this._y;
    if (Game.map[key] != "*") {
        alert("There is no box here!");
    } else if (key == Game.ananas) {
        alert("Hooray! You found an ananas and won this game.");
        Game.engine.lock();
        window.removeEventListener("keydown", this);
    } else {
        alert("This box is empty :-(");
    }
}

[edit] Pedro, the angry owner

The game is now winnable! Let's add a villain as a second actor. We will place him using the same algorithm we used previously. To do this, let's refactor the original _createPlayer method into a more useful parametrized factory _createBeing by passing a constructor function as an argument:

var Pedro = function(x, y) {
    this._x = x;
    this._y = y;
    this._draw();
}
 
Pedro.prototype._draw = function() {
    Game.display.draw(this._x, this._y, "P", "red");
}
 
Game._createBeing = function(what, freeCells) {
    var index = Math.floor(ROT.RNG.getUniform() * freeCells.length);
    var key = freeCells.splice(index, 1)[0];
    var parts = key.split(",");
    var x = parseInt(parts[0]);
    var y = parseInt(parts[1]);
    return new what(x, y);
}
 
Game._generateMap = function() {
    /* ...previous stuff... */
 
    this.player = this._createBeing(Player, freeCells);
    this.pedro = this._createBeing(Pedro, freeCells);
}
 
Game.init = function() {
    /* ...previous stuff... */
 
    scheduler.add(this.player, true);
    scheduler.add(this.pedro, true);
}

This might be confusing to some, but passing functions around (as function arguments, for instance) is very common in JavaScript.

[edit] Pathfinding-based AI

Pedro is missing its act() method so far. We are going to use one of rot.js's pathfinding functions to implement Pedro's behavior: ROT.Path.AStar (the A* algorithm). Some rudimentary scaffolding is necessary:

  1. The player must have public methods to read its position,
  2. We need a passableCallback function which tells the pathfinder what areas are passable,
  3. We need a pathCallback function, which will be called from within the pathfinder (to notify us about the shortest path found).

Moreover, to make Pedro somewhat weaker than player, we will use the pathfinder only in 4-topology.

Player.prototype.getX = function() { return this._x; }
 
Player.prototype.getY = function() { return this._y; }
 
Pedro.prototype.act = function() {
    var x = Game.player.getX();
    var y = Game.player.getY();
    var passableCallback = function(x, y) {
        return (x+","+y in Game.map);
    }
    var astar = new ROT.Path.AStar(x, y, passableCallback, {topology:4});
 
    var path = [];
    var pathCallback = function(x, y) {
        path.push([x, y]);
    }
    astar.compute(this._x, this._y, pathCallback);
}

We now have the shortest path between Pedro and the player, stored in the path variable. Note that Pedro's current position is also part of the path; that's why we first discard the first item of our path. If the resulting path is only one-cell long, Pedro is standing close to the player and the game is over (player lost). Otherwise, we apply the same movement logic we used for the player in Part 2 of this tutorial.

Pedro.prototype.act = function() {
    /* ...previous stuff... */
 
    path.shift(); /* remove Pedro's position */
    if (path.length == 1) {
        Game.engine.lock();
        alert("Game over - you were captured by Pedro!");
    } else {
        x = path[0][0];
        y = path[0][1];
        Game.display.draw(this._x, this._y, Game.map[this._x+","+this._y]);
        this._x = x;
        this._y = y;
        this._draw();
    }
 
}

Ta-dah! The game is complete now; it is possible to win and lose. Some considerations for possible further improvements:

  • Player can crash the game by moving onto Pedro's cell. Not only this is currently allowed, but it also disrupts Pedro's pathfinding (which expects the path to be at least two cells long).
  • The Game.map structure should probably store positions of beings (player, Pedro) as well.
  • It would be comfortable for users to increase the set of allowed navigation keys (number keys, vi keys).
  • When a box is inspected, its appearance may change (to make it easier for player to distinguish between visited and unvisited boxes).

And that's all for part 3. The whole working code is available at jsfiddle.net.

Personal tools