Tuesday, September 20, 2011

roguelike tutorial 10: items, inventory, inventory screens

Before we add potions, spellbooks, treasures, armor, weapons, food, and other roguelike goodies we need to think about inventory and start small. We'll need a new class for items, we'll need to update the world to track, add, and remove items, the creature class will need to be updated to pickup, use, and drop items, and the PlayScreen needs to be updated to display items and accept keystrokes to let the player actually pickup or drop items. Let's start with a new Item class.

package rltut;

import java.awt.Color;

public class Item {

    private char glyph;
    public char glyph() { return glyph; }

    private Color color;
    public Color color() { return color; }

    private String name;
    public String name() { return name; }

    public Item(char glyph, Color color, String name){
        this.glyph = glyph;
        this.color = color;
        this.name = name;
    }
}

Pretty simple so far. I didn't give it an x, y, or z coordinate because items don't need to know where they are; it kind of makes since to have them when laying on the ground but what about when they're in a container or being carried around by a creature?
I guess you could also make a Location interface. Then Point, Creature, and Item could implement it. That way an item's location could be a point in the world, a creature that's carrying it (or it's point in the world), or a container it's in. That would also be useful because an item would have a reference to wherever it is and whoever is carrying it. I'll have to try that on my next roguelike.
I guess you could have the owner update their location and add another flag indicating if the item is on the floor, in a container, or being carried. Sounds cumbersome and unnecessary; best to do without for now.

I'm happy with having a CreatureFactory to handle the details of creating a new creature so let's do the same for items. We could create an ItemFactory but I'd like to try something different: add items to the CreatureFactory. I haven't tried this before so I'm not sure if it's better to keep the two separate or not. I guess I'm going to find out.

The first step is the most powerful refactoring of all, Rename. We'll rename the CreatureFactory to something more general. I'm going to just call it StuffFactory. That's an atrociously non-descriptive name but I can rename it when I think of something better — of course temporary things often stay that way so this will probably remain a StuffFactory for a while.

And now we can add our first item.

public Item newRock(int depth){
        Item rock = new Item(',', AsciiPanel.yellow, "rock");
        world.addAtEmptyLocation(rock, depth);
        return rock;
    }

Now that we have an item to put in the world, we need to extend the world class to handle that. Instead of a list of all items I'm going to try something different — I'm only going to allow one item per tile. Good idea or bad, let's go ahead with that for now.

Our world needs one item per tile.

private Item[][][] items;

This should get initialized in the constructor.

this.items = new Item[width][height][depth];

We need a way to determine what item is in a location.

public Item item(int x, int y, int z){
    return items[x][y][z];
}

And a way to add an item to a random spot similar to how we add creatures.

public void addAtEmptyLocation(Item item, int depth) {
    int x;
    int y;
    
    do {
        x = (int)(Math.random() * width);
        y = (int)(Math.random() * height);
    }
    while (!tile(x,y,depth).isGround() || item(x,y,depth) != null);
    
    items[x][y][depth] = item;
}

And lastly, we need to update our methods that are used for displaying the world to also display items.

public char glyph(int x, int y, int z){
    Creature creature = creature(x, y, z);
    if (creature != null)
        return creature.glyph();
    
    if (item(x,y,z) != null)
        return item(x,y,z).glyph();
    
    return tile(x, y, z).glyph();
}
public Color color(int x, int y, int z){
    Creature creature = creature(x, y, z);
    if (creature != null)
        return creature.color();
    
    if (item(x,y,z) != null)
        return item(x,y,z).color();
    
    return tile(x, y, z).color();
}

And the only change to the PlayScreen is to add our new rocks.

private void createItems(StuffFactory factory) {
    for (int z = 0; z < world.depth(); z++){
        for (int i = 0; i < world.width() * world.height() / 20; i++){
            factory.newRock(z);
        }
    }
}

Just call that during setup of a new game and play it.


Now that we've got a world with items in it, we need to be able to pick them up and do stuff with them.

A lot can happen with a creature's inventory so let's create another class for that. Instead of using a list I'm going to use an array so the items index doesn't change when we lose something before it. E.g. if we quaff the potion in our 'd' slot, whatever was in the 'e' slot should remain there and not slide into the 'd' slot. If you want that kind of behavior then you could use a List — it's your choice.

package rltut;

public class Inventory {

    private Item[] items;
    public Item[] getItems() { return items; }
    public Item get(int i) { return items[i]; }

    public Inventory(int max){
        items = new Item[max];
    }
}

We need a method to add an item to the first open slot in our inventory.

public void add(Item item){
    for (int i = 0; i < items.length; i++){
        if (items[i] == null){
             items[i] = item;
             break;
        }
    }
}

And a way to remove an item from our inventory.

public void remove(Item item){
    for (int i = 0; i < items.length; i++){
        if (items[i] == item){
             items[i] = null;
             return;
        }
    }
}

We also need to know if the inventory is full and we can't carry any more.

public boolean isFull(){
    int size = 0;
    for (int i = 0; i < items.length; i++){
        if (items[i] != null)
             size++;
    }
    return size == items.length;
}

Now that we've got something to represent an inventory, we can add one to our Creature class. This means that potentially any creature can have an inventory (Spoiler alert!)

private Inventory inventory;
    public Inventory inventory() { return inventory; }

We need to initialize it in the constructor. I prefer smaller inventories since that means the player can't carry half the world with them; having to chose which two swords to bring with you is more interesting than just carrying them all. I also tend to forget what I've got once it goes beyond a screenfull.

this.inventory = new Inventory(20);

And our creatures need to be able to pickup and drop stuff, moving it from the world to the creatures inventory or back.

public void pickup(){
        Item item = world.item(x, y, z);
    
        if (inventory.isFull() || item == null){
            doAction("grab at the ground");
        } else {
            doAction("pickup a %s", item.name());
            world.remove(x, y, z);
            inventory.add(item);
        }
    }

public void drop(Item item){
        doAction("drop a " + item.name());
        inventory.remove(item);
        world.addAtEmptySpace(item, x, y, z);
    }


Your IDE has probably warned you that the world class doesn't support removing and adding items so let's take care of that. Removing an item is easy:

public void remove(int x, int y, int z) {
    items[x][y][z] = null;
}

Adding an item to a specific place is more complicated since we only allow one item per tile. Because of that, we need to check adjacent tiles for an open space and repeat until we find one or run out of open spaces.

public void addAtEmptySpace(Item item, int x, int y, int z){
    if (item == null)
        return;
    
    List<Point> points = new ArrayList<Point>();
    List<Point> checked = new ArrayList<Point>();
    
    points.add(new Point(x, y, z));
    
    while (!points.isEmpty()){
        Point p = points.remove(0);
        checked.add(p);
        
        if (!tile(p.x, p.y, p.z).isGround())
            continue;
         
        if (items[p.x][p.y][p.z] == null){
            items[p.x][p.y][p.z] = item;
            Creature c = this.creature(p.x, p.y, p.z);
            if (c != null)
                c.notify("A %s lands between your feet.", item.name());
            return;
        } else {
            List<Point> neighbors = p.neighbors8();
            neighbors.removeAll(checked);
            points.addAll(neighbors);
        }
    }
}

A funky side effect of this is that if there are no open spaces then the item won't be added but will no longer be in the creature's inventory - it will vanish from the game. You can either let that happen or somehow let the caller know that it hasn't been added and shouldn't be removed from the inventory. Or you could notify everyone in viewing distance that it has vanished. I'll leave that up to you. If you leave it as it is then there's no indication that the item vanished and that may be interpreted as a bug. If you tell users it happens they probably won't consider it a bug - just part of the game. This could also make a funny scenario: imagine being trapped in a room where the floor is covered in treasure but you can't pick any up since your inventory is full and there's no room to drop your useless rusty sword.

Here's one possibility:
public void drop(Item item){
    if (world.addAtEmptySpace(item, x, y, z)){
         doAction("drop a " + item.name());
         inventory.remove(item);
    } else {
         notify("There's nowhere to drop the %s.", item.name());
    }
}


The final step is to update the PlayScreen's respondToUserInput method so the user can actually pickup things. Some roguelikes use the 'g' key to get things, some use the ',' key, and some use either one.

switch (key.getKeyChar()){
         case 'g':
         case ',': player.pickup(); break;
         case '<': player.moveBy( 0, 0, -1); break;
         case '>': player.moveBy( 0, 0, 1); break;
         }

Try it out.


We can pick up some rocks and the code is there to drop them, but we don't have a way to specify what to drop. Code that isn't being used gives me a bad feeling so let's wire up the GUI to that drop method soon. Ideally the user will press the 'd' key, the GUI will ask what to drop, the user types the letter of the thing to drop, the player drops it, and we go back to the game. Remember all that time we spent creating Screen interface and thinking about cases with different rules for user input and output? Time to make a new Screen.

Actually, if we think about what we want to do with inventory, we can do better. Here's a few scenarios off the top of my head:
press 'd', ask what to drop, the user selects something that can be dropped, drop it
press 'q', ask what to quaff, the user selects something that can be quaffed, quaff it
press 'r', ask what to read, the user selects something that can be read, read it
press 't', ask what to throw, the user selects something that can be thrown, throw it
press 'e', ask what to eat, the user selects something that can be eaten, eat it
Notice a pattern? There's a key that get's pressed, some verb (drop, quaff, read), some check against the items (droppable, quaffable, readable), and some action (drop, quaff, read). The common behavior can be put in one class called InventoryBasedScreen and the specific details can be in subclasses. That way we can have a DropScreen, QuaffScreen, ReadScreen and others that all subclass the InventoryBasedScreen and just provide a few simple details.

Let's start with a basic InventoryBasedScreen:

package rltut.screens;

import java.awt.event.KeyEvent;
import java.util.ArrayList;
import rltut.Creature;
import rltut.Item;
import asciiPanel.AsciiPanel;

public abstract class InventoryBasedScreen implements Screen {

    protected Creature player;
    private String letters;

    protected abstract String getVerb();
    protected abstract boolean isAcceptable(Item item);
    protected abstract Screen use(Item item);

    public InventoryBasedScreen(Creature player){
        this.player = player;
        this.letters = "abcdefghijklmnopqrstuvwxyz";
    }
}

We need the reference to the player because that's the one who's going to do the work of dropping, quaffing, eating, etc. It's protected so that the subclasses can use it. The letters are so we can assign a letter to each inventory slot (If you allow the inventory to be larger then you need to add more characters). Maybe this should be part of the inventory class but I think this is the only place where we will use it so I'll put it here for now. We've also got abstract methods so our subclasses can specify the verb, what items are acceptable for the action, and a method to actually perform the action. Using an item returns a Screen since it may lead to a different screen, e.g. if we're going to throw something then we can transition into some sort of targeting screen.

Since this is a screen it needs to actually display some output. We not only ask what they want to use but go ahead and show a list of acceptable items.

public void displayOutput(AsciiPanel terminal) {
        ArrayList<String> lines = getList();
    
        int y = 23 - lines.size();
        int x = 4;

        if (lines.size() > 0)
            terminal.clear(' ', x, y, 20, lines.size());
    
        for (String line : lines){
            terminal.write(line, x, y++);
        }
    
        terminal.clear(' ', 0, 23, 80, 1);
        terminal.write("What would you like to " + getVerb() + "?", 2, 23);
    
        terminal.repaint();
    }

That should be pretty clear: write the list in the lower left hand corner and ask the user what to do. If you allow a larger inventory then you'll have to show two columns or scroll the list or something.

The getList method will make a list of all the acceptable items and the letter for each corresponding inventory slot.

private ArrayList<String> getList() {
        ArrayList<String> lines = new ArrayList<String>();
        Item[] inventory = player.inventory().getItems();
    
        for (int i = 0; i < inventory.length; i++){
            Item item = inventory[i];
        
            if (item == null || !isAcceptable(item))
                continue;
        
            String line = letters.charAt(i) + " - " + item.glyph() + " " + item.name();
        
            lines.add(line);
        }
        return lines;
    }

Now that we've got some output we need to respond to user input. The user can press escape to go back to playing the game, select a valid character to use, or some invalid key that will do nothing and keep them on the current screen.

public Screen respondToUserInput(KeyEvent key) {
        char c = key.getKeyChar();

        Item[] items = player.inventory().getItems();
    
        if (letters.indexOf(c) > -1
             && items.length > letters.indexOf(c)
             && items[letters.indexOf(c)] != null
             && isAcceptable(items[letters.indexOf(c)]))
            return use(items[letters.indexOf(c)]);
        else if (key.getKeyCode() == KeyEvent.VK_ESCAPE)
            return null;
        else
            return this;
    }

I hope that little mess makes sense. Use it, exit, or ask again.

This doesn't do anything yet, in fact it's an abstract class so it can't do anything until we create a subclass and use that. The first subclass will be a DropScreen.

package rltut.screens;

import rltut.Creature;
import rltut.Item;

public class DropScreen extends InventoryBasedScreen {

    public DropScreen(Creature player) {
        super(player);
    }
}

We just need to supply the methods that were abstract in the original. We're asking the use what they want to drop so the getVerb should return that.

protected String getVerb() {
        return "drop";
    }

Since anything can be dropped, all items are acceptable.

protected boolean isAcceptable(Item item) {
        return true;
    }

Once the user selects what to drop we tell the player to do the work and return null since we are done with the DropScreen.

protected Screen use(Item item) {
        player.drop(item);
        return null;
    }

Now we can update the PlayScreen to use our fancy new DropScreen. The DropScreen is a little different that the start, play, win, and lose screens since it needs to return to the PlayScreen once it's done. We could pass the current play screen into newly created DropScreen and have it return the PlayScreen when it's done, but I've tried that before and it became kind of messy. This time I'll try something different. We can have the PlayScreen know if we're working with another sub screen and delegate input and output to that screen screen. Once the subscreen is done, it get's set to null and the PlayScreen works as normal.

First the PlayScreen needs to know what the subscreen is. If it's null then everything should work as it did before. There's no need to initialize this since we check for nulls when we use it.

private Screen subscreen;

After we displayOutput the subscreen should get a chance to display. This way the current game world will be a background to whatever the subscreen wants to show.

if (subscreen != null)
    subscreen.displayOutput(terminal);

And any user input needs to be sent to the subscreen if it exists. The subscreen will also tell the PlayScreen what the new subscreen is. We also need to handle the users pressing the 'd' key to drop items from inventory. Lastly, if we should update the world only if we don't have a subscreen.

public Screen respondToUserInput(KeyEvent key) {
     if (subscreen != null) {
         subscreen = subscreen.respondToUserInput(key);
     } else {
         switch (key.getKeyCode()){
         case KeyEvent.VK_ESCAPE: return new LoseScreen();
         case KeyEvent.VK_ENTER: return new WinScreen();
         case KeyEvent.VK_LEFT:
         case KeyEvent.VK_H: player.moveBy(-1, 0, 0); break;
         case KeyEvent.VK_RIGHT:
         case KeyEvent.VK_L: player.moveBy( 1, 0, 0); break;
         case KeyEvent.VK_UP:
         case KeyEvent.VK_K: player.moveBy( 0,-1, 0); break;
         case KeyEvent.VK_DOWN:
         case KeyEvent.VK_J: player.moveBy( 0, 1, 0); break;
         case KeyEvent.VK_Y: player.moveBy(-1,-1, 0); break;
         case KeyEvent.VK_U: player.moveBy( 1,-1, 0); break;
         case KeyEvent.VK_B: player.moveBy(-1, 1, 0); break;
         case KeyEvent.VK_N: player.moveBy( 1, 1, 0); break;
         case KeyEvent.VK_D: subscreen = new DropScreen(player); break;
         }
        
         switch (key.getKeyChar()){
         case 'g':
         case ',': player.pickup(); break;
         case '<': player.moveBy( 0, 0, -1); break;
         case '>': player.moveBy( 0, 0, 1); break;
         }
     }
    
     if (subscreen == null)
         world.update();
    
     if (player.hp() < 1)
         return new LoseScreen();
    
     return this;
 }

That was a lot of little changes to a few different places but if the DropScreen is any indication, the InventoryBasedScreen should be a major win in terms of being able to implement new features with little effort. The PlayScreen is getting a little out of hand now that it creates a new world, displays the world, handles user commands and deals with subscreens. Maybe the part about setting up a new game should be moved somewhere else.


Let's make this a proper roguelike with a special object to retrieve and return to the surface with. This will also give the player a legitimate victory condition other than pressing enter.

Add our new item to the SuffFactory:

public Item newVictoryItem(int depth){
        Item item = new Item('*', AsciiPanel.brightWhite, "teddy bear");
        world.addAtEmptyLocation(item, depth);
        return item;
    }

And make sure it get's created when we start a new game:

private void createItems(StuffFactory factory) {
    for (int z = 0; z < world.depth(); z++){
        for (int i = 0; i < world.width() * world.height() / 20; i++){
            factory.newRock(z);
        }
    }
    factory.newVictoryItem(world.depth() - 1);
}

And update the WorldBuilder to include some exist stairs.

private WorldBuilder addExitStairs() {
        int x = -1;
        int y = -1;
    
        do {
            x = (int)(Math.random() * width);
            y = (int)(Math.random() * height);
        }
        while (tiles[x][y][0] != Tile.FLOOR);
    
        tiles[x][y][0] = Tile.STAIRS_UP;
        return this;
    }

And make that part of creating caves.

public WorldBuilder makeCaves() {
        return randomizeTiles()
             .smooth(8)
             .createRegions()
             .connectRegions()
             .addExitStairs();
    }


Our normal stair handling won't work with up stairs on the uppermost layer of the world so let's handle that in the PlayScreen.

switch (key.getKeyChar()){
    case 'g':
    case ',': player.pickup(); break;
    case '<':
        if (userIsTryingToExit())
         return userExits();
        else
         player.moveBy( 0, 0, -1); 
        break;
    case '>': player.moveBy( 0, 0, 1); break;
    }

private boolean userIsTryingToExit(){
    return player.z == 0 && world.tile(player.x, player.y, player.z) == Tile.STAIRS_UP;
}

private Screen userExits(){
    for (Item item : player.inventory().getItems()){
        if (item != null && item.name().equals("teddy bear"))
            return new WinScreen();
    }
    return new LoseScreen();
}

Now you can remove the cases for VK_ESCAPE and VK_ENTER. You can also remove the message about pressing escape or enter. It took half the tutorials but we finally have a victory condition.

download the code

5 comments:

  1. again.. wow! Big big tutorial! I really like your explanations, clear and easy to follow, keep writing!

    ReplyDelete
  2. Wow you did a great job with these tutorials! I just have a question...
    How would I implement the possibility to stack items in piles and search through them to pick up specific items?

    ReplyDelete
  3. @Ajikozau, Piles of items could be implemented as just a list of items (so for this tutorial, the world would have List[][][]) or as a new Item that acts as a pile. You'd have to update some of the methods that deal with getting or placing items in the world.

    Searching through piles would best be implemented as another screen.

    It can get pretty complicated depending on what you want.

    ReplyDelete
  4. Ok, thank you very much, finishing the tutorial gave me a few ideas on how to implement the search screen. I'll try that out. :)

    ReplyDelete
  5. There has been possible values like tutorial etc. which would help students around different areas of interest and these would further help them to move along with all those prospects.

    ReplyDelete