This is the twentieth post in the Building a Roguelike in Javascript series. I recommend you start at the beginning unless you've been following along. This part corresponds to part 15 in Trystan's series. All the code for this part can be found at the part 16 tag of the jsrogue repository. At the time of writing, I am still using the d4ea2ab commit for rot.js.

In this post we're going to add new screens to our game to make it easier to play as well as to make the environment more detailed. We'll start by adding a screen to examine the items in our inventory and get more details about them. Then we will add a look screen which effectively allows us to look around the map and get more details about items and creatures laying around. Finally we will add a help screen listing all the game commands!

Demo Link

The results after this post can be seen here.

assets/dynamicglyph.js

In order to make these screens meaningful, we'd like to be able to gather some details about items and entities, such as level, attack value, etc. We'd like mixins to be able to register that they provide some kind of detail(s) about the entity/item. Our event system seems perfect for this! However it currently doesn't provide any way of returning values, so let's fix that by making raiseEvent collect all return values and push them to an array.

1
2
3
4
5
6
7
8
9
Game.DynamicGlyph.prototype.raiseEvent = function(event) {
    // ...
    // Invoke each listener, with this entity as the context and the arguments
    var results = [];
    for (var i = 0; i < this._listeners[event].length; i++) {
        results.push(this._listeners[event][i].apply(this, args));
    }
    return results;
};

Now in order to make our detail providers to simply provide data and not have to worry about presentation, we'd like each mixin to return some details in an array like this:

[
    {key: 'food', value: 10},
    {key: 'attack', value: 20}
]


Now that we know the layout of our details, let's build a details function which will take care of gathering all these details and formatting them. It will just naively build a string, but I encourage you to play around with the code and make it prettier (eg. by sorting the value keys before building them as a string).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
Game.DynamicGlyph.prototype.details = function() {
    var details = [];
    var detailGroups = this.raiseEvent('details');
    // Iterate through each return value, grabbing the detaisl from the arrays.
    if (detailGroups) {
        for (var i = 0, l = detailGroups.length; i < l; i++) {
            if (detailGroups[i]) {
                for (var j = 0; j < detailGroups[i].length; j++) {
                    details.push(detailGroups[i][j].key + ': ' +  detailGroups[i][j].value);          
                }
            }
        }
    }
    return details.join(', ');
};

We're now ready to update our mixins to actually show some details!

assets/itemmixins.js

We want to add details for our Edible and Equippable mixins.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Game.ItemMixins.Edible = {
    // ...
    listeners: {
        'details': function() {
            return [{key: 'food', value: this._foodValue}];
        }
    }
};

Game.ItemMixins.Equippable = {
    // ...
    listeners: {
        'details': function() {
            var results = [];
            if (this._wieldable) {
                results.push({key: 'attack', value: this.getAttackValue()});
            }
            if (this._wearable) {
                results.push({key: 'defense', value: this.getDefenseValue()});
            }
            return results;
        }
    }
};

assets/entitymixins.js

Similarly, we'll want to add details for Attacker, Destructible and ExperienceGainer.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// ...
Game.EntityMixins.Attacker = {
    // ...
    listeners: {
        details: function() {
            return [{key: 'attack', value: this.getAttackValue()}];
        }
    }
};
// ...
Game.EntityMixins.Destructible = {
    // ...
    listeners: {
        onGainLevel: function() {
            // ...
        },
        details: function() {
            return [
                {key: 'defense', value: this.getDefenseValue()},
                {key: 'hp', value: this.getHp()}
            ];
        }
    }
};
// ...
Game.EntityMixins.ExperienceGainer = {
    // ...
    listeners: {
        onKill: function(victim) {
            // ...
        },
        details: function() {
            return [{key: 'level', value: this.getLevel()}];
        }
    }
};

assets/geometry.js

As part of the look screen and some future screens, we'll want to be able to render certain shapes to the screen. One example would be drawing a line from one point to another. We'll create a geometry utilities file which will keep all such functions. For now the only algorithm we need to implement is Bresenham's Line Algorithm. This function will accept a starting point and end point and return an array of all the points along the line.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
Game.Geometry = {
    getLine: function(startX, startY, endX, endY) {
        var points = [];
        var dx = Math.abs(endX - startX);
        var dy = Math.abs(endY - startY);
        var sx = (startX < endX) ? 1 : -1;
        var sy = (startY < endY) ? 1 : -1;
        var err = dx - dy;
        var e2;

        while (true) {
            points.push({x: startX, y: startY});
            if (startX == endX && startY == endY) {
                break;
            }
            e2 = err * 2;
            if (e2 > -dx) {
                err -= dy;
                startX += sx;
            }
            if (e2 < dx){
                err += dx;
                startY += sy;
            }
        }

        return points;
    }
};

index.html

Before we forget, let's include this new file in our list of sources!

1
2
3
4
5
    <!-- ... -->
    <script src="assets/utilities.js"></script>
    <script src="assets/geometry.js"></script>
    <script src="assets/screens.js"></script>
    <!-- ... -->

assets/screens.js

The first screen we want to build is the examine screen. This will allow the player to look at an item in their inventory and get more details about them.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
Game.Screen.examineScreen = new Game.Screen.ItemListScreen({
    caption: 'Choose the item you wish to examine',
    canSelect: true,
    canSelectMultipleItems: false,
    isAcceptable: function(item) {
        return true;
    },
    ok: function(selectedItems) {
        var keys = Object.keys(selectedItems);
        if (keys.length > 0) {
            var item = selectedItems[keys[0]];
            Game.sendMessage(this._player, "It's %s (%s).", 
                [
                    item.describeA(false),
                    item.details()
                ]);
        }
        return true;
    }
});

Now we want to be able to actually show this screen! Let's assign it to the x key.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
Game.Screen.playScreen = {
    // ..
    handleInput: function(inputType, inputData) {
        // ...
        if (inputType === 'keydown') {
            // ...
            } else if (inputData.keyCode === ROT.VK_W) {
                // ...
            } else if (inputData.keyCode === ROT.VK_X) {
                // Show the drop screen
                this.showItemsSubScreen(Game.Screen.examineScreen, this._player.getItems(),
                   'You have nothing to examine.');
                return;
            } else if (inputData.keyCode === ROT.VK_COMMA) {
                // ...
            }
        }
        // ...
    },
    // ...
};

assets/tile.js

Before we go ahead and implement the look screen, we need to add a description to our tiles. That way if we put our pointer over a water tile, it would tell us that we're looking at water.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
Game.Tile = function(properties) {
    // ...
    this._description = properties['description'] || '';
};
// ...
Game.Tile.prototype.getDescription = function() {
    return this._description;
};

Game.Tile.nullTile = new Game.Tile({description: '(unknown)'});
Game.Tile.floorTile = new Game.Tile({
    // ...
    description: 'A cave floor'
});
Game.Tile.wallTile = new Game.Tile({
    // ...
    description: 'A cave wall'
});
Game.Tile.stairsUpTile = new Game.Tile({
    // ...
    description: 'A rock staircase leading upwards'
});
Game.Tile.stairsDownTile = new Game.Tile({
    // ...
    description: 'A rock staircase leading downwards'
});
Game.Tile.holeToCavernTile = new Game.Tile({
    // ...
    description: 'A great dark hole in the ground'
});
Game.Tile.waterTile = new Game.Tile({
    // ...
    description: 'Murky blue water'
});

assets/glyph.js

In order to make the look screen pretty, it would be nice if we could show the colored glyph representation for a given glyph. Let's add a helper method which adds the coloring information to a glyph string.

1
2
3
4
Game.Glyph.prototype.getRepresentation = function() {
    return '%c{' + this._foreground + '}%b{' + this._background + '}' + this._char +
        '%c{white}%b{black}';
};

assets/screens.js

Now we'd like to create the look screen. This screen will allow us to select a cell on the map and get more information about it. You can imagine that this type of screen could be re-used for many features, such as casting a spell on a given cell, shooting a dagger at a cell, etc. We will create a TargetBasedScreen which allows us to target a given cell and perform a function on that cell, similar to our ItemListScreen. Before we can do that, we will refactor the play screen's tile rendering into a seperate function so that our TargetBasedScreen subscreen can still render the map's tiles. We'll also create a function called getScreenOffsets which determines the top left X and Y position.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
Game.Screen.playScreen = {
    // ...
    render: function(display) {
        // Render subscreen if there is one
        if (this._subScreen) {
            this._subScreen.render(display);
            return;
        }

        var screenWidth = Game.getScreenWidth();
        var screenHeight = Game.getScreenHeight();

        // Render the tiles
        this.renderTiles(display);

        // Get the messages in the player's queue and render them
        // ...
    },
    getScreenOffsets: function() {
        // Make sure we still have enough space to fit an entire game screen
        var topLeftX = Math.max(0, this._player.getX() - (Game.getScreenWidth() / 2));
        // Make sure we still have enough space to fit an entire game screen
        topLeftX = Math.min(topLeftX, this._player.getMap().getWidth() -
            Game.getScreenWidth());
        // Make sure the y-axis doesn't above the top bound
        var topLeftY = Math.max(0, this._player.getY() - (Game.getScreenHeight() / 2));
        // Make sure we still have enough space to fit an entire game screen
        topLeftY = Math.min(topLeftY, this._player.getMap().getHeight() - Game.getScreenHeight());
        return {
            x: topLeftX,
            y: topLeftY
        };
    },
    renderTiles: function(display) {
        var screenWidth = Game.getScreenWidth();
        var screenHeight = Game.getScreenHeight();
        var offsets = this.getScreenOffsets();
        var topLeftX = offsets.x;
        var topLeftY = offsets.y;
        // This object will keep track of all visible map cells
        var visibleCells = {};
        // Store this._player.getMap() and player's z to prevent losing it in callbacks
        var map = this._player.getMap();
        var currentDepth = this._player.getZ();
        // Find all visible cells and update the object
        map.getFov(currentDepth).compute(
            this._player.getX(), this._player.getY(), 
            this._player.getSightRadius(), 
            function(x, y, radius, visibility) {
                visibleCells[x + "," + y] = true;
                // Mark cell as explored
                map.setExplored(x, y, currentDepth, true);
            });
        // Render the explored map cells
        // ...
        }
    },
    // ...
};

Our TargetBasedScreen will have a cursor which we can move around the screen. An optional caption function is used to allow updating the caption on the bottom based on the position. It will also have an optional ok function which will receive the cursor position if we hit enter, and allow us to consume a turn for doing things such as casting a spell. The screen will be setup with the initial cursor position as well as the screen offset. We won't implement screen scrolling in this screen class for now, but I encourage you to do it as a side project!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
Game.Screen.TargetBasedScreen = function(template) {
    template = template || {};
    // By default, our ok return does nothing and does not consume a turn.
    this._isAcceptableFunction = template['okFunction'] || function(x, y) {
        return false;
    };
    // The defaut caption function simply returns an empty string.
    this._captionFunction = template['captionFunction'] || function(x, y) {
        return '';
    }
};

Game.Screen.TargetBasedScreen.prototype.setup = function(player, startX, startY, offsetX, offsetY) {
    this._player = player;
    // Store original position. Subtract the offset to make life easy so we don't
    // always have to remove it.
    this._startX = startX - offsetX;
    this._startY = startY - offsetY;
    // Store current cursor position
    this._cursorX = this._startX;
    this._cursorY = this._startY;
    // Store map offsets
    this._offsetX = offsetX;
    this._offsetY = offsetY;
    // Cache the FOV
    var visibleCells = {};
    this._player.getMap().getFov(this._player.getZ()).compute(
        this._player.getX(), this._player.getY(), 
        this._player.getSightRadius(), 
        function(x, y, radius, visibility) {
            visibleCells[x + "," + y] = true;
        });
    this._visibleCells = visibleCells;
};

Game.Screen.TargetBasedScreen.prototype.render = function(display) {
    Game.Screen.playScreen.renderTiles.call(Game.Screen.playScreen, display);

    // Draw a line from the start to the cursor.
    var points = Game.Geometry.getLine(this._startX, this._startY, this._cursorX,
        this._cursorY);

    // Render stars along the line.
    for (var i = 0, l = points.length; i < l; i++) {
        display.drawText(points[i].x, points[i].y, '%c{magenta}*');
    }

    // Render the caption at the bottom.
    display.drawText(0, Game.getScreenHeight() - 1, 
        this._captionFunction(this._cursorX + this._offsetX, this._cursorY + this._offsetY));
};

Game.Screen.TargetBasedScreen.prototype.handleInput = function(inputType, inputData) {
    // Move the cursor
    if (inputType == 'keydown') {
        if (inputData.keyCode === ROT.VK_LEFT) {
            this.moveCursor(-1, 0);
        } else if (inputData.keyCode === ROT.VK_RIGHT) {
            this.moveCursor(1, 0);
        } else if (inputData.keyCode === ROT.VK_UP) {
            this.moveCursor(0, -1);
        } else if (inputData.keyCode === ROT.VK_DOWN) {
            this.moveCursor(0, 1);
        } else if (inputData.keyCode === ROT.VK_ESCAPE) {
            Game.Screen.playScreen.setSubScreen(undefined);
        } else if (inputData.keyCode === ROT.VK_RETURN) {
            this.executeOkFunction();
        }
    }
    Game.refresh();
};

Game.Screen.TargetBasedScreen.prototype.moveCursor = function(dx, dy) {
    // Make sure we stay within bounds.
    this._cursorX = Math.max(0, Math.min(this._cursorX + dx, Game.getScreenWidth()));
    // We have to save the last line for the caption.
    this._cursorY = Math.max(0, Math.min(this._cursorY + dy, Game.getScreenHeight() - 1));
};

Game.Screen.TargetBasedScreen.prototype.executeOkFunction = function() {
    // Switch back to the play screen.
    Game.Screen.playScreen.setSubScreen(undefined);
    // Call the OK function and end the player's turn if it return true.
    if (this._okFunction(this._cursorX + this._offsetX, this._cursorY + this._offsetY)) {
        this._player.getMap().getEngine().unlock();
    }
};

Now that we have all our base, let's create our actual look screen!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
Game.Screen.lookScreen = new Game.Screen.TargetBasedScreen({
    captionFunction: function(x, y) {
        var z = this._player.getZ();
        var map = this._player.getMap();
        // If the tile is explored, we can give a better capton
        if (map.isExplored(x, y, z)) {
            // If the tile isn't explored, we have to check if we can actually 
            // see it before testing if there's an entity or item.
            if (this._visibleCells[x + ',' + y]) {
                var items = map.getItemsAt(x, y, z);
                // If we have items, we want to render the top most item
                if (items) {
                    var item = items[items.length - 1];
                    return String.format('%s - %s (%s)',
                        item.getRepresentation(),
                        item.describeA(true),
                        item.details());
                // Else check if there's an entity
                } else if (map.getEntityAt(x, y, z)) {
                    var entity = map.getEntityAt(x, y, z);
                    return String.format('%s - %s (%s)',
                        entity.getRepresentation(),
                        entity.describeA(true),
                        entity.details());
                }
            }
            // If there was no entity/item or the tile wasn't visible, then use
            // the tile information.
            return String.format('%s - %s',
                map.getTile(x, y, z).getRepresentation(),
                map.getTile(x, y, z).getDescription());

        } else {
            // If the tile is not explored, show the null tile description.
            return String.format('%s - %s',
                Game.Tile.nullTile.getRepresentation(),
                Game.Tile.nullTile.getDescription());
        }
    }
});

All that's missing for our look screen is to wire up the key for it! Let's also create the help screen while we're building screens.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// Define our help screen
Game.Screen.helpScreen = {
    render: function(display) {
        var text = 'jsrogue help';
        var border = '-------------';
        var y = 0;
        display.drawText(Game.getScreenWidth() / 2 - text.length / 2, y++, text);
        display.drawText(Game.getScreenWidth() / 2 - text.length / 2, y++, border);
        display.drawText(0, y++, 'The villagers have been complaining of a terrible stench coming from the cave.');
        display.drawText(0, y++, 'Find the source of this smell and get rid of it!');
        y += 3;
        display.drawText(0, y++, '[,] to pick up items');
        display.drawText(0, y++, '[d] to drop items');
        display.drawText(0, y++, '[e] to eat items');
        display.drawText(0, y++, '[w] to wield items');
        display.drawText(0, y++, '[W] to wield items');
        display.drawText(0, y++, '[x] to examine items');
        display.drawText(0, y++, '[;] to look around you');
        display.drawText(0, y++, '[?] to show this help screen');
        y += 3;
        text = '--- press any key to continue ---';
        display.drawText(Game.getScreenWidth() / 2 - text.length / 2, y++, text);
    },
    handleInput: function(inputType, inputData) {
        Game.Screen.playScreen.setSubScreen(null);
    }
};

And we're almost done! The final step is to add the key handlers for the look and the help screens. The help screen will use ? and the look screen will use ;.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
Game.Screen.playScreen = {
    // ...
    handleInput: function(inputType, inputData) {
        // ...
        if (inputType === 'keydown') {
            // ...
        } else if (inputType === 'keypress') {
            var keyChar = String.fromCharCode(inputData.charCode);
            // ...
            } else if (keyChar === ';') {
                // Setup the look screen.
                var offsets = this.getScreenOffsets();
                Game.Screen.lookScreen.setup(this._player,
                    this._player.getX(), this._player.getY(),
                    offsets.x, offsets.y);
                this.setSubScreen(Game.Screen.lookScreen);
                return;
            } else if (keyChar === '?') {
                // Setup the look screen.
                this.setSubScreen(Game.Screen.helpScreen);
                return;
            } else {
                // Not a valid key
                // ...
            }
        } 
    },
};

assets/entities.js

If you run the name now, you may notice that something looks kind of off when you look at yourself. That's because I forgot to specify a name for the player's entity! Let's fix that.

1
2
3
4
Game.PlayerTemplate = {
    name: 'human (you)',
    // ...
};

Conclusion

In this post we introduced three new screens! In particular, we added a new base screen for targetting which will come in useful in the future. The next post will introduce ranged weapons and throwable objects, so make sure to stay updated! I found that the code base has been getting a bit unwieldly while writing this post and am regretting stuffing all the mixins and screens in a single files, so I may do some refactoring in between this post and the next to clean this up. If I do, I'll make sure to tell you at the beginning of next post so that you can update your code.

I hope you enjoyed this post! Remember that all the code for this part can be found at the part 16 tag of the jsrogue repository. As always please feel free to post any comments whether questions, clarifications or criticism!

Thanks for reading,

Dominic