Tuesday, September 6, 2011

roguelike tutorial 06: hitpoints, combat, and messages

Currently our hero just gets rid of any foe it bumps into. I think it's time our creatures had a real attack.

public void attack(Creature other){
        int amount = Math.max(0, attackValue() - other.defenseValue());
    
        amount = (int)(Math.random() * amount) + 1;
    
        other.modifyHp(-amount);
    }

    public void modifyHp(int amount) {
        hp += amount;
    
        if (hp < 1)
         world.remove(this);
    }
There's many many different ways to handle how much damage is done but I'll stick with something simple: the damage amount is a random number from 1 to the attackers attack value minus the defenders defense value. It's easy to code, easy to understand, and using only two variables worked fine for Catlevania: Symphony Of The Night. The IDE tells us what else we need to add to our creature class to support this.
    private int maxHp;
    public int maxHp() { return maxHp; }

    private int hp;
    public int hp() { return hp; }

    private int attackValue;
    public int attackValue() { return attackValue; }

    private int defenseValue;
    public int defenseValue() { return defenseValue; }

We can rely on constructor injection to set the values.

public Creature(World world, char glyph, Color color, int maxHp, int attack, int defense){
    this.world = world;
    this.glyph = glyph;
    this.color = color;
    this.maxHp = maxHp;
    this.hp = maxHp;
    this.attackValue = attack;
    this.defenseValue = defense;
}

Then update our CreatureFactory.

public Creature newPlayer(){
    Creature player = new Creature(world, '@', AsciiPanel.brightWhite, 100, 20, 5);
    world.addAtEmptyLocation(player);
    new PlayerAi(player);
    return player;
}

public Creature newFungus(){
    Creature fungus = new Creature(world, 'f', AsciiPanel.green, 10, 0, 0);
    world.addAtEmptyLocation(fungus);
    new FungusAi(fungus, this);
    return fungus;
}

As always, play around with the numbers and find something you like.


Now that we have some stats, let's display them on the PlayScreen. Here's what I added to the end of displayOutput:

    String stats = String.format(" %3d/%3d hp", player.hp(), player.maxHp());
    terminal.write(stats, 1, 23);

That went well. Adding new functionality was kept to a few small and isolated changes. The earlier decision to move all the creature creation to a factory also reduced the number of places we had to update. Now go fight some fungi!



Since this post is so short let's add another feature: messages. There are so many ways to do this but we're going to think of a way that avoids globals. A global message queue seems like the easiest way to do messaging - and it probably is - but sometimes it's a good idea to stick to a guideline like "don't use global variables" to see if it works in all scenarios and find where the guideline works against you and should be abandoned. I've found that small roguelikes are a perfect place for this kind of experimentation.

Since we're not using a global message queue to hold messages, where should we put the messages? Messages are just extra text for the GUI so maybe they should be part of the PlayScreen? But they're created by things in the world and everything has access to the world so maybe there? That doesn't seem right though. Messages are meant for the player so maybe the PlayerAi should be the receiver of the messages? That kind of make sense because it already gets called by the creature class and creatures are probably going to be the source of most messages. We can pass messages to the ai and any non-player ai can just ignore the messages.

Add a notify method to the creature class. To make it easier for the callers to build messages you can take the string and any parameters the caller passes and format the string for them. A nice convenience.
public void notify(String message, Object ... params){
    ai.onNotify(String.format(message, params));
}

Which means you need the corresponding empty method for the CreatureAi class. We'll make the PlayerAi class add the messages to a list. Other CreatureAi's will just ignore it.

package rltut;

import java.util.List;

public class PlayerAi extends CreatureAi {

    private List<String> messages;

    public PlayerAi(Creature creature, List<String> messages) {
        super(creature);
        this.messages = messages;
    }

    public void onEnter(int x, int y, Tile tile){
        if (tile.isGround()){
         creature.x = x;
         creature.y = y;
        } else if (tile.isDiggable()) {
         creature.dig(x, y);
        }
    }

    public void onNotify(String message){
        messages.add(message);
    }
}

Instead of creating a getter for the message list we rely on constructor injection. That means the list comes from somewhere else that may already have a reference to it. We can create the list in the PlayScreen and pass it to the creature factory which passes it to the new PlayerAi. Since the PlayScreen already has the list, it can easily display any messages that show up and clear the list afterwards.

Here's the update to the CreatureFactory:

public Creature newPlayer(List<String> messages){
    Creature player = new Creature(world, '@', AsciiPanel.brightWhite, 100, 20, 5);
    world.addAtEmptyLocation(player);
    new PlayerAi(player, messages);
    return player;
}

Here's the update to the PlayScreen:

private List<String> messages;

public PlayScreen(){
    screenWidth = 80;
    screenHeight = 21;
    messages = new ArrayList<String>();
    createWorld();
    
    CreatureFactory creatureFactory = new CreatureFactory(world);
    createCreatures(creatureFactory);
}

private void createCreatures(CreatureFactory creatureFactory){
    player = creatureFactory.newPlayer(messages);
    
    for (int i = 0; i < 8; i++){
        creatureFactory.newFungus();
    }
}
Displaying messages can also be done many different ways. If you haven't guessed by now I like to start simple. The simplest way I can think of is to just list them all on the screen at once.
private void displayMessages(AsciiPanel terminal, List<String> messages) {
    int top = screenHeight - messages.size();
    for (int i = 0; i < messages.size(); i++){
        terminal.writeCenter(messages.get(i), top + i);
    }
    messages.clear();
}
Just call that from the displayOutput method. Before clearing the message list, the messages could be copied into a separate list (or list of lists) so the history is preserved.

All we need now is some actual messages. Go ahead and notify the creature wherever it does something interesting or has something happen to it. Here's a sample of what I added to the attack method:
notify("You attack the '%s' for %d damage.", other.glyph, amount);
other.notify("The '%s' attacks you for %d damage.", glyph, amount);

And now the player can receive notices of what's going on.



What about notifying nearby creatures when something happens? Here's one way to do that:
public void doAction(String message, Object ... params){
        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);
         
             if (other == null)
                 continue;
         
             if (other == this)
                 other.notify("You " + message + ".", params);
             else
                 other.notify(String.format("The '%s' %s.", glyph, makeSecondPerson(message)), params);
         }
    }
}
The method makeSecondPerson does a small bit of string manipulation to make it grammatically correct. It assumes the first word is the verb, but that's easy enough to do as long as you don't plan on supporting other languages. It's best to avoid implicit rules like this since the only way to know about it is to already know it or watch it fail when you don't follow the implicit rule. It feels dirty to have gramer rules in with the Creature code so remember to move it somewhere better.
private String makeSecondPerson(String text){
    String[] words = text.split(" ");
    words[0] = words[0] + "s";
    
    StringBuilder builder = new StringBuilder();
    for (String word : words){
        builder.append(" ");
        builder.append(word);
    }
    
    return builder.toString().trim();
}

Then you can call doAction in your creature code and anyone nearby will be notified. Here's some examples:

when the FungusAi spawns a child:
creature.doAction("spawn a child");
while attacking:
doAction("attack the '%s' for %d damage", other.glyph, amount);
or when dying:
public void modifyHp(int amount) {
    hp += amount;
    
    if (hp < 1) {
        doAction("die");
        world.remove(this);
    }
}

Now when playing you can get messages about all these details going on in the world. The more you add the more detailed and deep the world will be.

download the code

12 comments:

  1. Thanks for sharing your tutorial like this. Really funny to see. And nice to learn from.
    Btw, I'm still having screen-troubles with your game... It flashes after some movements :(

    ReplyDelete
  2. Hi!
    this time my question is about a dialog system.
    Did you thought about this?

    My idea:
    A dialog is a tree-like (think about Baldur's Gate or neverwinter night system) path, something like:

    1 -> 2 -> 3
    |--> 4 -> 5 -> 3

    or something similar


    1) keep dialogs one for npc and for simplicity store it into a file

    2) load dialog dialog file into a Dialog class for creature

    3) display it

    my problem is: how to gather input from player and display properly with AsciiPanel?

    ReplyDelete
  3. @Jocke: I think the jar file that the 'download the code' links to has an old version of AsciiPanel. If you download the current jar file () and use that instead of the old one, it should fix any display issues. If not, let me know and I'll look into it more.

    ReplyDelete
  4. @Marte: I'm familiar with the idea of dialog tree's but I've never used them. Perhaps you could start with a DialogScreen?

    ReplyDelete
  5. Hey!
    I have a problem: My IDE shows no errors, but when I run it I get this in the log:
    Exception in thread "AWT-EventQueue-0" java.lang.NullPointerException
    at rpgsystem.playScreen.displayMessages(playScreen.java:104)
    at rpgsystem.playScreen.displayOutput(playScreen.java:39)
    at rpgsystem.Main.repaint(Main.java:36)
    at rpgsystem.Main.keyPressed(Main.java:42)
    at java.awt.Component.processKeyEvent(Component.java:6463)
    at java.awt.Component.processEvent(Component.java:6282)
    at java.awt.Container.processEvent(Container.java:2229)
    at java.awt.Window.processEvent(Window.java:2022)
    at java.awt.Component.dispatchEventImpl(Component.java:4861)
    at java.awt.Container.dispatchEventImpl(Container.java:2287)
    at java.awt.Window.dispatchEventImpl(Window.java:2719)
    at java.awt.Component.dispatchEvent(Component.java:4687)
    at java.awt.KeyboardFocusManager.redispatchEvent(KeyboardFocusManager.java:1893)
    at java.awt.DefaultKeyboardFocusManager.dispatchKeyEvent(DefaultKeyboardFocusManager.java:752)
    at java.awt.DefaultKeyboardFocusManager.preDispatchKeyEvent(DefaultKeyboardFocusManager.java:1017)
    at java.awt.DefaultKeyboardFocusManager.typeAheadAssertions(DefaultKeyboardFocusManager.java:889)
    at java.awt.DefaultKeyboardFocusManager.dispatchEvent(DefaultKeyboardFocusManager.java:717)
    at java.awt.Component.dispatchEventImpl(Component.java:4731)
    at java.awt.Container.dispatchEventImpl(Container.java:2287)
    at java.awt.Window.dispatchEventImpl(Window.java:2719)
    at java.awt.Component.dispatchEvent(Component.java:4687)
    at java.awt.EventQueue.dispatchEventImpl(EventQueue.java:703)
    at java.awt.EventQueue.access$000(EventQueue.java:102)
    at java.awt.EventQueue$3.run(EventQueue.java:662)
    at java.awt.EventQueue$3.run(EventQueue.java:660)
    at java.security.AccessController.doPrivileged(Native Method)
    at java.security.ProtectionDomain$1.doIntersectionPrivilege(ProtectionDomain.java:76)
    at java.security.ProtectionDomain$1.doIntersectionPrivilege(ProtectionDomain.java:87)
    at java.awt.EventQueue$4.run(EventQueue.java:676)
    at java.awt.EventQueue$4.run(EventQueue.java:674)
    at java.security.AccessController.doPrivileged(Native Method)
    at java.security.ProtectionDomain$1.doIntersectionPrivilege(ProtectionDomain.java:76)
    at java.awt.EventQueue.dispatchEvent(EventQueue.java:673)
    at java.awt.EventDispatchThread.pumpOneEventForFilters(EventDispatchThread.java:244)
    at java.awt.EventDispatchThread.pumpEventsForFilter(EventDispatchThread.java:163)
    at java.awt.EventDispatchThread.pumpEventsForHierarchy(EventDispatchThread.java:151)
    at java.awt.EventDispatchThread.pumpEvents(EventDispatchThread.java:147)
    at java.awt.EventDispatchThread.pumpEvents(EventDispatchThread.java:139)
    at java.awt.EventDispatchThread.run(EventDispatchThread.java:97)
    This is without the code for notifying nearby creatures, just the messages.
    any idea what could be going wrong?

    ReplyDelete
    Replies
    1. It appears to be something wrong with messages.size

      Delete
    2. Your List messages is likely not intialized. Make sure in the constructor that you have:

      this.messages = new ArrayList();

      and that it comes BEFORE you call:

      createCreatures(creatureFactory);

      in the constructor.

      Delete
  6. This comment has been removed by the author.

    ReplyDelete
  7. Hello

    I don't get this line - why do you multiply ox and oy and compare it to r?

    if(ox * ox + oy*oy > r * r)
    continue;

    I get that you want to check an area around the creature/player from -9 to 10 but why this multiplying logic to sort out non-matching positions?

    I would be thankful for an explanation.

    Edit: Okay I actually figured it out by myself. The logic does not search around the creature in an 9x9 block but just at the places that are in near sight around it

    For example, if r = 2, the following shows the area

    - = Will not be considered
    X = Will be considered
    @ = actual creature

    ..-2-1 0 1 2 . x-axis
    -2 - - X - -
    -1 - X X X -
    0 X X @ X X
    1 - X X X -
    2 - - X - -

    y-axis

    ReplyDelete
  8. I love this tutorial! I noticed you could make makeSecondPerson simpler, without the use of a loop and StringBuffer:

    private String makeSecondPerson(String message) {
    int space = message.indexOf(" ");

    if (space == -1) {
    return message+"s";
    } else {
    return message.substring(0, space) + "s" + message.substring(space);
    }
    }

    ReplyDelete
  9. This is AWESOME!
    I have to say, that I don't get all the details yet. How the codeflow ist and how all these different things play together. But I ram reading again and again.
    Thank you very much for putting this together.

    For me, it worked like a charm and everything was fine.
    If someone needs help, I am willed to help out.

    ReplyDelete
  10. There exist to be more understanding and reliable piece guides for the students and hopefully by the time they would immediately be able to form all those objects herein.

    ReplyDelete