Friday, October 21, 2011

roguelike tutorial 19: mana, spells, and magic books

Time to add some magic to our game. This will take a lot of code and will take a long time to balance. To keep it simple, we'll just use mana and spellbooks. Scrolls are easy to add since they're basically potions that you read instead of quaff so I won't add scrolls - but I'm sure you can figure out how to do that by now or at least you will be able to after this tutorial.



We're going to have spells and each will have it's own effect. But when we add that effect to a creature, we want each creature to get it's own effect, otherwise weird things will happen because the spell will have an effect being applied to many creatures and the shared state (like duration) will be wonky. Instead, a spell will have an effect and when applying it to something we can create a copy and apply the copy. That way each time you cast a spell the effect will have it's own state. Add a copy constructor to the Effect class like this:

public Effect(Effect other){
    this.duration = other.duration; 
}


The first new class we'll need is a Spell class to tie together a spell name, cost, and effect.

package rltut;

public class Spell {

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

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

    private Effect effect;
    public Effect effect() { return new Effect(effect); }

    public Spell(String name, int manaCost, Effect effect){
        this.name = name;
        this.manaCost = manaCost;
        this.effect = effect;
    }
}
See how we return a copy of the effect instead of the original?


Let's add the mana related stuff to the Creature class.

private int maxMana;
    public int maxMana() { return maxMana; }
    
    private int mana;
    public int mana() { return mana; }
    public void modifyMana(int amount) { mana = Math.max(0, Math.min(mana+amount, maxMana)); 
    
    private int regenManaCooldown;
    private int regenManaPer1000;
    public void modifyRegenManaPer1000(int amount) { regenManaPer1000 += amount; }

    private void regenerateMana(){
        regenManaCooldown -= regenManaPer1000;
        if (regenManaCooldown < 0){
            if (mana < maxMana) {
                modifyMana(1);
                modifyFood(-1);
            }
            regenManaCooldown += 1000;
        }
    }

Don't forget to initialize regenManaPer1000 in the constructor and to call regenerateMana during the update method. We also have something else we can gain during a level-up.
public void gainMaxMana() {
        maxMana += 5;
        mana += 5;
        doAction("look more magical");
    }

    public void gainRegenMana(){
        regenManaPer1000 += 5;
        doAction("look a little less tired");
    }


And add the new options to the LevelUpController.
new LevelUpOption("Increased mana"){
            public void invoke(Creature creature) { creature.gainMaxMana(); }
        },new LevelUpOption("Increased mana regeneration"){
            public void invoke(Creature creature) { creature.gainRegenMana(); }
        }


It would also be nice to add our new stats to the displayOutput method of the PlayScreen class.
String stats = String.format(" %3d/%3d hp  %d/%d mana  %8s", 
    player.hp(), player.maxHp(), player.mana(), player.maxMana(), hunger());  


Adding spell books could be done many different ways. I think we should stick with the Item class and extend it with what we need.
private List<Spell> writtenSpells;
    public List<Spell> writtenSpells() { return writtenSpells; }

    public void addWrittenSpell(String name, int manaCost, Effect effect){
        writtenSpells.add(new Spell(name, manaCost, effect));
    }

Don't forget to initialize writtenSpells in the Item constructor. This should allow us to create scrolls, spell books, notes, or even engraved items. Very simple and flexible. If we create an effect that just displays a note to the user then we could even let the player add his own non-magical engravings to items.

Let's add some spell books. I'm going to create two simple ones but you should add more spells and more books. These are just examples of what can be done. The first is a healer type book:
public Item newWhiteMagesSpellbook(int depth) {
        Item item = new Item('+', AsciiPanel.brightWhite, "white mage's spellbook");
        item.addWrittenSpell("minor heal", 4, new Effect(1){
            public void start(Creature creature){
                if (creature.hp() == creature.maxHp())
                    return;
                
                creature.modifyHp(20);
                creature.doAction("look healthier");
            }
        });
        
        item.addWrittenSpell("major heal", 8, new Effect(1){
            public void start(Creature creature){
                if (creature.hp() == creature.maxHp())
                    return;
                
                creature.modifyHp(50);
                creature.doAction("look healthier");
            }
        });
        
        item.addWrittenSpell("slow heal", 12, new Effect(50){
            public void update(Creature creature){
                super.update(creature);
                creature.modifyHp(2);
            }
        });

        item.addWrittenSpell("inner strength", 16, new Effect(50){
            public void start(Creature creature){
                creature.modifyAttackValue(2);
                creature.modifyDefenseValue(2);
                creature.modifyVisionRadius(1);
                creature.modifyRegenHpPer1000(10);
                creature.modifyRegenManaPer1000(-10);
                creature.doAction("seem to glow with inner strength");
            }
            public void update(Creature creature){
                super.update(creature);
                if (Math.random() < 0.25)
                    creature.modifyHp(1);
            }
            public void end(Creature creature){
                creature.modifyAttackValue(-2);
                creature.modifyDefenseValue(-2);
                creature.modifyVisionRadius(-1);
                creature.modifyRegenHpPer1000(-10);
                creature.modifyRegenManaPer1000(10);
            }
        });
        
        world.addAtEmptyLocation(item, depth);
        return item;
    }

And the second has a hodgepodge of spells that do weird things, mostly to show what can be done within the effects:
public Item newBlueMagesSpellbook(int depth) {
        Item item = new Item('+', AsciiPanel.brightBlue, "blue mage's spellbook");

        item.addWrittenSpell("blood to mana", 1, new Effect(1){
            public void start(Creature creature){
                int amount = Math.min(creature.hp() - 1, creature.maxMana() - creature.mana());
                creature.modifyHp(-amount);
                creature.modifyMana(amount);
            }
        });
        
        item.addWrittenSpell("blink", 6, new Effect(1){
            public void start(Creature creature){
                creature.doAction("fade out");
                
                int mx = 0;
                int my = 0;
                
                do
                {
                    mx = (int)(Math.random() * 11) - 5;
                    my = (int)(Math.random() * 11) - 5;
                }
                while (!creature.canEnter(creature.x+mx, creature.y+my, creature.z)
                        && creature.canSee(creature.x+mx, creature.y+my, creature.z));
                
                creature.moveBy(mx, my, 0);
                
                creature.doAction("fade in");
            }
        });
        
        item.addWrittenSpell("summon bats", 11, new Effect(1){
            public void start(Creature creature){
                for (int ox = -1; ox < 2; ox++){
                    for (int oy = -1; oy < 2; oy++){
                        int nx = creature.x + ox;
                        int ny = creature.y + oy;
                        if (ox == 0 && oy == 0 
                                || creature.creature(nx, ny, creature.z) != null)
                            continue;
                        
                        Creature bat = newBat(0);
                        
                        if (!bat.canEnter(nx, ny, creature.z)){
                            world.remove(bat);
                            continue;
                        }
                        
                        bat.x = nx;
                        bat.y = ny;
                        bat.z = creature.z;
                        
                        creature.summon(bat);
                    }
                }
            }
        });
        
        item.addWrittenSpell("detect creatures", 16, new Effect(75){
            public void start(Creature creature){
                creature.doAction("look far off into the distance");
                creature.modifyDetectCreatures(1);
            }
            public void end(Creature creature){
                creature.modifyDetectCreatures(-1);
            }
        });
        world.addAtEmptyLocation(item, depth);
        return item;
    }

I had to add a couple methods to the creature class to support these.
public void summon(Creature other) {
        world.add(other);
    }
    
    private int detectCreatures;
    public void modifyDetectCreatures(int amount) { detectCreatures += amount; }
    
    public void castSpell(Spell spell, int x2, int y2) {
        Creature other = creature(x2, y2, z);
        
        if (spell.manaCost() > mana){
            doAction("point and mumble but nothing happens");
            return;
        } else if (other == null) {
            doAction("point and mumble at nothing");
            return;
        }
        
        other.addEffect(spell.effect());
        modifyMana(-spell.manaCost());
    }

And update one method:
public boolean canSee(int wx, int wy, int wz){
        return (detectCreatures > 0 && world.creature(wx, wy, wz) != null
                || ai.canSee(wx, wy, wz));
    }

You should also add a potion to restore mana.


Now we just need a ReadScreen to select an item to read, a ReadSpell screen to select a spell from the item, and a CastSpell screen to select a target for the spell. You should be able to figure those out but let's do them in the opposite order:
package rltut.screens;

import rltut.Creature;
import rltut.Spell;

public class CastSpellScreen extends TargetBasedScreen {
    private Spell spell;
    
    public CastSpellScreen(Creature player, String caption, int sx, int sy, Spell spell) {
        super(player, caption, sx, sy);
        this.spell = spell;
    }
    
    public void selectWorldCoordinate(int x, int y, int screenX, int screenY){
        player.castSpell(spell, x, y);
    }
}


The ReadSpellScreen is very similar to the InventoryBasedScreen.
package rltut.screens;

import java.awt.event.KeyEvent;
import java.util.ArrayList;

import rltut.Creature;
import rltut.Item;
import rltut.Spell;

import asciiPanel.AsciiPanel;

public class ReadSpellScreen implements Screen {

    protected Creature player;
    private String letters;
    private Item item;
    private int sx;
    private int sy;
    
    public ReadSpellScreen(Creature player, int sx, int sy, Item item){
        this.player = player;
        this.letters = "abcdefghijklmnopqrstuvwxyz";
        this.item = item;
        this.sx = sx;
        this.sy = sy;
    }
    
    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 read?", 2, 23);
        
        terminal.repaint();
    }
    
    private ArrayList<String> getList() {
        ArrayList<String> lines = new ArrayList<String>();
        
        for (int i = 0; i < item.writtenSpells().size(); i++){
            Spell spell = item.writtenSpells().get(i);
            
            String line = letters.charAt(i) + " - " + spell.name() + " (" + spell.manaCost() + " mana)";
            
            lines.add(line);
        }
        return lines;
    }

    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) {
            return use(item.writtenSpells().get(letters.indexOf(c)));
        } else if (key.getKeyCode() == KeyEvent.VK_ESCAPE) {
            return null;
        } else {
            return this;
        }
    }

    protected Screen use(Spell spell){
        return new CastSpellScreen(player, "", sx, sy, spell);
    }
}


And the ReadScreen is simple enough.

package rltut.screens;

import rltut.Creature;
import rltut.Item;

public class ReadScreen extends InventoryBasedScreen {

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

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

    protected boolean isAcceptable(Item item) {
        return !item.writtenSpells().isEmpty();
    }

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


Update the PlayScreen to add spell books, map the r key to the new ReadScreen, and update the HelpScreen.



I know this is already a very long tutorial, but there's one little thing that bugs me. The creature class has some special methods that are only called when gaining a level, the gain* methods. They're only called from one place and they're small enough that we can just inline them. Eclipse, and most IDEs, can do this for you, just select the method name, right click, Refactor, Inline.



(days later....) I just realized a different, and almost certainly better, way of handling effects for each spell. Instead of each spell having a reference to an Effect and using the Effect's copy constructor, the spell class should act as an Effect factory and have an abstract newEffect method. Each individual spell would subclass Spell and implement newEffect. I'll have to do that with my next roguelike.

download the code

10 comments:

  1. I don't seem to be able to get magic to work with this code... is it me or is there a problem of some kind?

    ReplyDelete
  2. @Ajukozau, this magic code is probably the most complex code in the tutorial and I had a hard time presenting it in a way that is easy to understand so I'm not surprised it isn't working for you. How is it not working? Are you able to get the new screens working? Targeting? Are some spells working and others aren't?

    ReplyDelete
  3. I downloaded the code from your site (lesson 20) and ran it but spells refuses to have any ffect, they drain mana but nothing else, potions work fine though.

    ReplyDelete
  4. Ok I fiddled around with it, changing Spell class to this seemed to fix the problem (and subsequently modifying other classes):
    package rltut;

    public class Spell {

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

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

    private Effect effect;
    public Effect effect() {
    Effect tEff = new Effect(effect);
    return effect;
    }

    public boolean requiresTarget;
    public boolean requiresTarget() { return requiresTarget; }



    public Spell(String name, int manaCost, Effect effect, boolean requiresTarget){
    this.name = name;
    this.manaCost = manaCost;
    this.effect = effect;
    this.requiresTarget = requiresTarget;
    }
    }

    ReplyDelete
  5. respondToUserInput in ReadSpellScreen has line:
    Item[] items = player.inventory().getItems();
    which is later used to determine if choice isn't out of bounds. This means you must have 4 items in your possession to be able to cast 4 spells.
    You should obviously use item.writtenSpells() here.

    Other than that it seems something is wrong with copy constructor we created. See, what Ajikozau essentially changed(which helped me as well) was
    return new Effect(effect);
    into
    return effect;

    Second line works, first does not. I don't have time to figure it right now, but I've marked it with TODO:

    ReplyDelete
    Replies
    1. Yup, I just tested it.
      return new Effect(effect);
      doesn't copy overriden function start(creature) in Effect class. Just add default action to start(creature), like
      doAction("The spell was not very effective");

      Delete
    2. Yeah, the spell stuff wasn't very well thought out.

      Delete
    3. Yup. I'll probably make it completely different. Just for sake of finishing tutorial, before I write the systems I want the way I want:

      things will work as intended if the code will be like that in Spell class:
      public Effect effect() {
      return (Effect)effect.clone();
      }

      But for that we have to make Effect implement Cloneable. With this we get protected clone() function, so we override it with public version:

      public Object clone()
      {
      try {
      return super.clone();
      }
      catch (CloneNotSupportedException e) {
      // This should never happen
      throw new InternalError(e.toString());
      }
      }

      And viola, we can give separate instances of effect to each monster affected, and everything works as intended.

      Delete
  6. Those are the true prospects and would surely help students to proceed with all those possible opinions as considered to be of essential nature. drupal themes development

    ReplyDelete