Tuesday, October 25, 2011

roguelike tutorial 20: item appearance and identification

One of the things I enjoy the most about roguelikes is identifying items. Usually not by scroll of identify or funky unicorn tricks, but by boldly quaffing and reading — yeah, I tend to die a lot. Identification is something I haven't seen in other games and something that I think any good roguelike needs. The basic idea is that if we've identified an item, or given it a name of our own, then we see the name, otherwise we see it's appearance.

So let's start by adding an appearance to the Item class.

private String appearance;
public String appearance() { 
    if (appearance == null)
        return name;

    return appearance;
}

Then have it passed in to the constructor.

Since the appearance has to be passed in, our code is broken in all the places that create items - this is a good thing because now we can see what areas we need to change. If appearance is null then the name is used since most things look like what they are; e.g. a rock looks like a rock and a sword looks like a sword. Because of this we can pass a null appearance to all items except the potions. Before we get to the potions we need to set up some colors and text to use in our factory.

private Map<String, Color> potionColors;
    private List<String> potionAppearances;
 
    public StuffFactory(World world){
        this.world = world;
  
        setUpPotionAppearances();
    }
 
    private void setUpPotionAppearances(){
        potionColors = new HashMap<String, Color>();
        potionColors.put("red potion", AsciiPanel.brightRed);
        potionColors.put("yellow potion", AsciiPanel.brightYellow);
        potionColors.put("green potion", AsciiPanel.brightGreen);
        potionColors.put("cyan potion", AsciiPanel.brightCyan);
        potionColors.put("blue potion", AsciiPanel.brightBlue);
        potionColors.put("magenta potion", AsciiPanel.brightMagenta);
        potionColors.put("dark potion", AsciiPanel.brightBlack);
        potionColors.put("grey potion", AsciiPanel.white);
        potionColors.put("light potion", AsciiPanel.brightWhite);

        potionAppearances = new ArrayList<String>(potionColors.keySet());
        Collections.shuffle(potionAppearances);
    }

This creates some text ("red potion", "green potion") and some corresponding colors (red, green), and shuffles the text so each time you create a new factory they will be in a different order. Since we create a new factory once per game, each game will have different text and colors.

Now to set the potion color, just use the new data when creating potions.
String appearance = potionAppearances.get(0);
  Item item = new Item('!', potionColors.get(appearance), "health potion", appearance);
...and...
String appearance = potionAppearances.get(1);
  Item item = new Item('!', potionColors.get(appearance), "mana potion", appearance);
...etc.

Not the best way but it works well enough and is easy to show.

If you play the game the potions should be random colors now.



Our CreatureAi class needs to record what items have been identified and what the names of identified or renamed items are. We could have this tracked by the items themselves or some global variables, but this way creatures can identify and converse about item appearances too. By modeling it as close to reality as possible, i.e. where creature's have their own mind and names for items, interesting possibilities and emergent behavior are more likely.

private Map<String, String> itemNames;

    public String getName(Item item){
        String name = itemNames.get(item.name());
        return name == null ? item.appearance() : name;
    }
 
    public void setName(Item item, String name){
        itemNames.put(item.name(), name);
    }

And we need this to be available to the creatures.

public String nameOf(Item item){
    return ai.getName(item);
}
 
public void learnName(Item item){
    notify("The " + item.appearance() + " is a " + item.name() + "!");
    ai.setName(item, item.name());
}

Wherever we use an item's name we need to use nameOf instead. The easiest way I can think of doing this is to make the name method private and see where the code breaks. Those places need to use the creature's nameOf method instead. The only places we need the real name is in the PlayScreen where we check to see if the player has the Teddy Bear, or whatever the victory object is, and in the CreatureAi getName and setName methods. Once we make all the other changes we can make the name method public and everything should compile again.

Run it and you should see that potions are now listed by color instead of the real name. I got an error when starting but that was fixed by setting up the GoblinAi before the Goblin is given a weapon and armor.


Now we need to let the player identify things when used. In the StuffFactory, for each quaffEffect make the creature learn the name of the potion if any effect happens. Here's an example:

public Item newPotionOfHealth(int depth){
    String appearance = potionAppearances.get(0);
    final Item item = new Item('!', potionColors.get(appearance), "health potion", appearance);
    item.setQuaffEffect(new Effect(1){
        public void start(Creature creature){
           if (creature.hp() == creature.maxHp())
               return;
    
           creature.modifyHp(15);
           creature.doAction("look healthier");
           creature.learnName(item);
       }
    });
  
    world.addAtEmptyLocation(item, depth);
    return item;
}

The Creature's throwAttack should also let the thrower learn the name of anything that has a quaffEffect. Now when you play you can learn the identity of potions by quaffing or throwing them.



It's still possible to learn the name of an Effect that you shouldn't learn. If you kill a bat with a yellow potion you shouldn't be able to see the effect it had or identify what the potion is since the bat died from being hit by a bottle and there wasn't anything left for the effect to apply to. Let's create a version of the doAction method that handles this logic. This version of doAction will take a message and an item. Anyone who can see the creature should see the message and learn the identity of the item, unless the creature is dead, then nothing happens. We can reuse some of the code in the current doAction.

public void doAction(String message, Object ... params){
    for (Creature other : getCreaturesWhoSeeMe()){
        if (other == this){
            other.notify("You " + message + ".", params);
        } else {
            other.notify(String.format("The %s %s.", name, makeSecondPerson(message)), params);
        }
    }
}
 
public void doAction(Item item, String message, Object ... params){
    if (hp < 1)
        return;
  
    for (Creature other : getCreaturesWhoSeeMe()){
        if (other == this){
            other.notify("You " + message + ".", params);
        } else {
            other.notify(String.format("The %s %s.", name, makeSecondPerson(message)), params);
        }
        other.learnName(item);
    }
}
 
private List<Creature> getCreaturesWhoSeeMe(){
    List<Creature> others = new ArrayList<Creature>();
    int r = 9;
    for (int ox = -r; ox < r+1; ox++){
        for (int oy = -r; oy < r+1; oy++){
            if (ox*ox + oy*oy > r*r)
                continue;
    
            Creature other = world.creature(x+ox, y+oy, z);
    
            if (other == null)
                continue;
    
            others.add(other);
        }
    }
    return others;
}

A possibility:
The goblin quaffs a cyan potion.
The goblin looks stronger.
The cyan potion is a strength potion!

If the potions' quaffEffect use this new doAction then things will work a little better and we can remove the learnName from throwAttack since everyone who see's the creature will identify the potion based on the effect. This also means that if your creatures quaff potions everyone who watches can figure out what it is.


And that's one way of doing item identification. We had to make a lot of little changes all over the place, like using the creature's nameOf instead of the item's name method, but it wasn't difficult or error-prone. Each creature has it's own idea of what things are named so you could even let intelligent creatures tell each other the names. Maybe each goblin should quaff one unidentified potion during it's life and then discuss with others.

public void discussItemName(Item item, Creature other){
    creature.doAction(item, "say \"%ss are %ss\"", item.appearance(), item.name());
    other.learnName(item);
}

Or all goblins could share the same itemNames map — although that's kind of cheating.

You could also let the player give names to things. A RenameItemScreen that let's you rename a specific item or all items with a specific appearance. That way if the player deduces that red potions are healing potions then they can rename all red potions to healing potions or if they like their individual sword they can rename it Excalibur.


One last addition: add a "cause of death" string to the creature class and pass one in to modifyHp.

private String causeOfDeath;
 public String causeOfDeath() { return causeOfDeath; }
 
 public void modifyHp(int amount, String causeOfDeath) { 
     hp += amount;
     this.causeOfDeath = causeOfDeath;
  
     if (hp > maxHp) {
         hp = maxHp;
     } else if (hp < 1) {
         doAction("die");
         leaveCorpse();
         world.remove(this);
     }
 }
Then update the places that modify hp. Here's an example:
public void modifyFood(int amount) { 
    food += amount;
  
    if (food > maxFood) {
        maxFood = (maxFood + food) / 2;
        food = maxFood;
        notify("You can't belive your stomach can hold that much!");
        modifyHp(-1, "Killed by overeating.");
    } else if (food < 1 && isPlayer()) {
        modifyHp(-1000, "Starved to death.");
    }
 }
Then pass the player to the WinScreen and LoseScreen and you can tell the user details about how they died, what they were carrying, conduct, etc. download the code

14 comments:

  1. public String appearance() {
    if (appearance == null)
    return name;
    return appearance(); <--- infinite recursion?
    }

    ReplyDelete
  2. @ubersam, You're right: it should return the private field, not recurse. I fixed it.

    ReplyDelete
  3. Hey Trystan (love your name, my brothers name is Tristan. Like your spelling way better.) I finished all of your tutorials. I need some help making a class system (such as mage, warrior, barbarian, etc.) My ideas was a screen that lists your choices, then you press the corresponding key to your choice. Can you help point me in the right direction? I have the screen made, just trying to figure out how to implement the choices.

    ReplyDelete
    Replies
    1. There's a few ways you could implement classes based on what you want them to be. If they just modify your starting stats and equipment then each class could just be a method that modifies the player. If they change some behavior then you could make each class a subclass of Creature and override the parts you want to change. You could have a separate CreatureClass that the Creature class references since composition is usually better than inheritance. I'm not a big fan of the class-based systems so I haven't given it much thought. Sometimes it's best to pick an idea and prototype it. After you make some progress, try it again in a different way. Eventually you'll find something that works for what you want.

      Delete
  4. Alright, thanks Trystan! Also, I'm working on making rivers run through my map, so I built in the world generation a script that gets a random number, and checks its value. Depending on its value, it will change Y by 1, -1, and x by 1 or -1. My plan was to put this inside of a for loop, but I get a huge list of errors which I don't really understand. Heres the script:
    int x = -1;
    int y = -1;

    do {
    x = (int)(Math.random() * width);
    y = (int)(Math.random() * height);
    }
    while (tiles[x][y][0] != Tile.FLOOR);
    for(int i = 0; i < 200; i++){
    if(Math.random() < .5){
    tiles[x][y][0] = Tile.WATER;
    double q = Math.random();
    if(q < .26)
    tiles[x++][y][0] = Tile.WATER;
    else if(q > .25 && q < .51)
    tiles[x--][y][0] = Tile.WATER;
    else if(q > .5 && q < .76)
    tiles[x][y++][0] = Tile.WATER;
    else
    tiles[x][y--][0] = Tile.WATER;
    }
    }
    return this;

    If you need to see the list of errors, I can post them but to me it's a bunch of gibberish.

    ReplyDelete
    Replies
    1. That script doesn't give any errors. The only problem is if I put THAT inside a for loops to make it produce more than one river, it doesn't work.

      Delete
    2. Actually that script doesn't work. For some reason it didn't make a river (because the if statement was false) three times in a row. Do you have any suggestions? I'm trying to make something like ADOM's rivers, or the ones in your 7DRL

      Delete
    3. nevermind, I fixed it. Just had to add a check on whether or not it was outside the boundaries. Any ideas on something to make water different from other tiles?

      Delete
  5. Thanks a dozen for this tutorial! Just like 'private String hunger()' in PlayScreen, do you know how to show information about the mob/item that you hover over? Do you have to make something like 'private String showInfo()'?

    ReplyDelete
    Replies
    1. There's a lot of different ways to do it but having a showInfo method on mobs, items, and tiles sounds like a good idea. They when the player hovers over something (I think there's a mouse movement event you can listen for), you find that tile and anything on it. Then display the mob, or item, or tile info.

      Delete
    2. Thanks for the response! Would this need a change to "FLOOR((char)250, AsciiPanel.yellow, "A dirt and rock cave floor.")," etc..

      Delete
    3. Sorry, I forgot to add; do I need to change anything in the TargetBasedScreen to make it work with mouse movement?

      Delete
  6. The opinions have been well cited above and hopefully for the future these would bring around all those possible objects which must have been followed herein. here

    ReplyDelete
  7. You have to update InventoryBasedScreen to use nameOf() in getList() method.

    ReplyDelete