Tuesday, October 11, 2011

roguelike tutorial 16: throwing and ranged weapons

Now that we've got our TargetBasedScreen let's make some more things with it. We've already got items so let's make a ThrowScreen. If we add ranged weapons then we could create a FireWeaponScreen.

First let's add another value to represent how much damage is done when an item is thrown.
private int thrownAttackValue;
    public int thrownAttackValue() { return thrownAttackValue; }
    public void modifyThrownAttackValue(int amount) { thrownAttackValue += amount; }

We can set the default to 1 in the constructor and update the StuffFactory to give a value to things that are good for throwing like certain weapons or rocks. Don't forget to update the item class's details method.


Now let's add a couple methods to the Creature class. One throws an item to a location and one damages any creature there.
public void throwItem(Item item, int wx, int wy, int wz) {
        Point end = new Point(x, y, 0);
    
        for (Point p : new Line(x, y, wx, wy)){
            if (!realTile(p.x, p.y, z).isGround())
                break;
            end = p;
        }
    
        wx = end.x;
        wy = end.y;
    
        Creature c = creature(wx, wy, wz);
    
        if (c != null)
            throwAttack(item, c);
        else
            doAction("throw a %s", item.name());
    
        unequip(item);
        inventory.remove(item);
        world.addAtEmptySpace(item, wx, wy, wz);
    }

And now actual attacks with thrown weapons. I'll add half the base attack value of the thrower since thrown weapons should generally do less damage than mele weapons.
private void throwAttack(Item item, Creature other) {
        modifyFood(-1);
    
        int amount = Math.max(0, attackValue / 2 + item.thrownAttackValue() - other.defenseValue());
    
        amount = (int)(Math.random() * amount) + 1;
    
        doAction("throw a %s at the %s for %d damage", item.name(), other.name, amount);
    
        other.modifyHp(-amount);
    
        if (other.hp < 1)
            gainXp(other);
    }


The throwing screen will be interesting because it's the first time we have a double screen scenario. We need a subclass of InventoryBasedScreen to select what we want to throw and then a subclass of a TargetBasedScreen to pick what to throw it at.

The ThrowAtScreen is simple and self explanatory. We can throw at anything we can see that isn't blocked by walls.
package rltut.screens;

import rltut.Creature;
import rltut.Item;
import rltut.Line;
import rltut.Point;

public class ThrowAtScreen extends TargetBasedScreen {
    private Item item;

    public ThrowAtScreen(Creature player, int sx, int sy, Item item) {
        super(player, "Throw " + item.name() + " at?", sx, sy);
        this.item = item;
    }

    public boolean isAcceptable(int x, int y) {
        if (!player.canSee(x, y, player.z))
            return false;
    
        for (Point p : new Line(player.x, player.y, x, y)){
            if (!player.realTile(p.x, p.y, player.z).isGround())
                return false;
        }
    
        return true;
    }

    public void selectWorldCoordinate(int x, int y, int screenX, int screenY){
        player.throwItem(item, x, y, player.z);
    }
}

The ThrowScreen is also a simple InventoryBasedScreen except we need to pass some values that aren't needed by the ThrowScreen but are used by the ThrowAtScreen.
package rltut.screens;

import rltut.Creature;
import rltut.Item;

public class ThrowScreen extends InventoryBasedScreen {
    private int sx;
    private int sy;

    public ThrowScreen(Creature player, int sx, int sy) {
        super(player);
        this.sx = sx;
        this.sy = sy;
    }

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

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

    protected Screen use(Item item) {
        return new ThrowAtScreen(player, sx, sy, item);
    }
}

Just add throwing to the HelpScreen and the respondToUserInput method of the PlayScreen and then try it.
case KeyEvent.VK_T: subscreen = new ThrowScreen(player,
        player.x - getScrollX(),
        player.y - getScrollY()); break;

Now you have a use for those rocks that are scattered around.


It's great that we can throw but how about some ranged weapons? Let's update the Item class.
private int rangedAttackValue;
    public int rangedAttackValue() { return rangedAttackValue; }
    public void modifyRangedAttackValue(int amount) { rangedAttackValue += amount; }

Anything that has a non zero rangedAttackValue is a ranged weapon. This way a weapon can have separate attack values for melee, thrown, and ranged combat. We'll keep it simple and say that ranged weapons can hit anything we can see that isn't blocked by terrain.

Add new ranged weapons to the StuffFactory. Bows are good at ranged combat but not very good at hitting things up close.
public Item newBow(int depth){
        Item item = new Item(')', AsciiPanel.yellow, "bow");
        item.modifyAttackValue(1);
        item.modifyRangedAttackValue(5);
        world.addAtEmptyLocation(item, depth);
        return item;
    }
Make sure new weapons gan be created when asking for a randomWeapon.
public Item randomWeapon(int depth){
        switch ((int)(Math.random() * 3)){
        case 0: return newDagger(depth);
        case 1: return newSword(depth);
        case 2: return newBow(depth);
        default: return newStaff(depth);
        }
    }

Creatures need a way to attack with ranged weapons. I'll add half the attack value like with thrown items.
public void rangedWeaponAttack(Creature other){
        modifyFood(-1);
    
        int amount = Math.max(0, attackValue / 2 + weapon.rangedAttackValue() - other.defenseValue());
    
        amount = (int)(Math.random() * amount) + 1;
    
        doAction("fire a %s at the %s for %d damage", weapon.name(), other.name, amount);
    
        other.modifyHp(-amount);
    
        if (other.hp < 1)
            gainXp(other);
    }

The FireWeaponScreen is similar to the ThrowAtScreen. We can fire our weapon at anything we can see that isn't blocked by walls.
package rltut.screens;

import rltut.Creature;
import rltut.Line;
import rltut.Point;

public class FireWeaponScreen extends TargetBasedScreen {

    public FireWeaponScreen(Creature player, int sx, int sy) {
        super(player, "Fire " + player.weapon().name() + " at?", sx, sy);
    }

    public boolean isAcceptable(int x, int y) {
        if (!player.canSee(x, y, player.z))
            return false;
    
        for (Point p : new Line(player.x, player.y, x, y)){
            if (!player.realTile(p.x, p.y, player.z).isGround())
                return false;
        }
    
        return true;
    }

    public void selectWorldCoordinate(int x, int y, int screenX, int screenY){
        Creature other = player.creature(x, y, player.z);
    
        if (other == null)
            player.notify("There's no one there to fire at.");
        else
            player.rangedWeaponAttack(other);
    }
}

Just add firing to the HelpScreen and the respondToUserInput method of the PlayScreen and then try it.
case KeyEvent.VK_F:
        if (player.weapon() == null || player.weapon().rangedAttackValue() == 0)
         player.notify("You don't have a ranged weapon equiped.");
        else
         subscreen = new FireWeaponScreen(player,
             player.x - getScrollX(),
             player.y - getScrollY()); break;


Creatures sometimes use up an item and it no longer exists. Other times they no longer have the item but it still exists in the world. Either way, we need to make sure they no longer have it equipped and that they no longer have it in their inventory. One way to do this is to create two helper methods and call these when possible.
private void getRidOf(Item item){
        inventory.remove(item);
        unequip(item);
    }

    private void putAt(Item item, int wx, int wy, int wz){
        inventory.remove(item);
        unequip(item);
        world.addAtEmptySpace(item, wx, wy, wz);
    }


The attack weapons all have similar bodies so we could use an Extract Method refactoring to move the commonalities to a separate method and use Extract Parameter or Parameterize Method to pass in the differences.

public void meleeAttack(Creature other){
        commonAttack(other, attackValue(), "attack the %s for %d damage", other.name);
    }

    private void throwAttack(Item item, Creature other) {
        commonAttack(other, attackValue / 2 + item.thrownAttackValue(), "throw a %s at the %s for %d damage", item.name(), other.name);
    }

    public void rangedWeaponAttack(Creature other){
        commonAttack(other, attackValue / 2 + weapon.rangedAttackValue(), "fire a %s at the %s for %d damage", weapon.name(), other.name);
    }

    private void commonAttack(Creature other, int attack, String action, Object ... params) {
        modifyFood(-2);
    
        int amount = Math.max(0, attack - other.defenseValue());
    
        amount = (int)(Math.random() * amount) + 1;
    
        Object[] params2 = new Object[params.length+1];
        for (int i = 0; i < params.length; i++){
         params2[i] = params[i];
        }
        params2[params2.length - 1] = amount;
    
        doAction(action, params2);
    
        other.modifyHp(-amount);
    
        if (other.hp < 1)
            gainXp(other);
    }

This particular implementation has the hidden side effect that the message that is passed in must end in a damage indicator since the commonAttack method will add that. It's not good to do things like this too often, but sometimes that happens.

download the code

2 comments:

  1. randomWeapon: random is 0-3, but you draw 0-2.

    ReplyDelete
    Replies
    1. It doesn't matter, because Math.random() returns a number between 0 and almost 1. When you cast it to an integer, it loses everything after the decimal point, so it will never reach the number that you multiply it by. It will choose one below it.

      Delete