Roguelike Tutorial Week 3: The Wraith

A brief note about the code samples: My goal is to make it possible to get the gist of what I did, but not to provide a full tutorial. If something makes no sense, please tell me, but if something doesn’t run exactly as presented it’s probably because it uses other code that’s in the Github repo but that I didn’t find interesting or important enough to reproduce here.

Last week we looked at some tweaking and barely any functional changes. This week I looked at my giant list of features I was considering and implemented the most complicated one. It all started with an idea for a monster: the wraith. The overall “spec” is:

  • It doesn’t attack normally; it attempts to “haunt” the player, destroying itself if it succeeds.
  • It is incorporeal and needs to move through walls.
  • It can follow the player even if the player breaks line of sight.
  • It should deal a lot of damage (to be a threat despite attacking only once), but it should also not suddenly blow a player up with little warning. So the damage will be dealt over time.
  • Being an intangible ghost, it probably shouldn’t have a visible corpse.

A New Monster

First things first, I needed to create a new monster. This is when I ran into a disagreement with the way the tutorial does some things. In the tutorial, the code that creates and defines a monster is all stuffed into the place_entities function. Personally I prefer to have the monster definitions together, ideally separate from the level generation, so it’s easy to call on them whenever needed (imagine a scroll or monster ability that summons other monsters, for instance). So I ended up putting this together:

import tcod as libtcod

import components.ai as ai

from components.fighter import Fighter
from entity import Entity
from render_functions import RenderOrder

class Monster(Entity):
    def __init__(self, x, y, char, color, name, blocks=True, render_order=RenderOrder.ACTOR):
    super().__init__(x, y, char, color, name, blocks, render_order)

class Orc(Monster):
    def __init__(self, x, y):
        super().__init__(x, y, 'o', libtcod.desaturated_green, 'Orc', blocks=True, render_order=RenderOrder.ACTOR)
        Fighter(hp=20, defense=0, power=4, xp=35).add_to_entity(self)
     ai.BasicMonster().add_to_entity(self)

class Troll(Monster):
    def __init__(self, x, y):
        super().__init__(x, y, 'T', libtcod.darker_green, 'Troll', blocks=True, render_order=RenderOrder.ACTOR)

        Fighter(hp=30, defense=2, power=8, xp=100).add_to_entity(self)
        ai.BasicMonster().add_to_entity(self)

(I think it’s possible I’m going a bit overboard with the OOP stuff, which is why I’m careful to state this as a preference rather than The Right Way)

With this in mind, our monster generation code now looks like this:

# imports now include "import map_objects.monsters as monsters"

if not any([entity for entity in entities if entity.x == x and entity.y == y]):
    monster_choice = random_choice_from_dict(monster_chances)
    if monster_choice == 'orc':
        monster = monsters.Orc(x, y)
    else:
        monster = monsters.Troll(x, y)

    entities.append(monster)

Now let’s add the most trivial possible monster type: the same thing but with beefier stats:

class Balrog(Monster):
    def __init__(self, x, y):
        super().__init__(x, y, 'B', libtcod.dark_flame, 'Balrog', blocks=True, render_order=RenderOrder.ACTOR)
        Fighter(hp=45, defense=4, power=12, xp=250).add_to_entity(self)
        ai.BasicMonster().add_to_entity(self)

From there it’s a simple matter of updating the monster_chances and above if statement to add a third option in game_map.py, which is easy enough.

I usually playtest my monsters by making all generated monsters be that monster. Note that the starting player can’t even damage the Balrog with its starting stats.

A New Component

Given the “damages over time” aspect of the wraith, I wanted a way to represent a temporary status effect on the player. The existing inventory gives a decent template for it. Once I opened that door, I realized that I already had a second status effect I wanted to add. The tutorial game as presented is super easy, and a big part of that is that there’s too much healing. A strategy of “drink potions at 10 or lower HP, always pick Agility as the level bonus, and only fight monsters one at a time in hallways” will generally result in the player becoming invincible without ever seriously being in danger.

A gradual heal stops at least part of this problem by making it so the player can’t jump from 10 health to 50 in one turn. So I created a HealOverTime status effect to add it to a potion:

from components.component import Component

from game_messages import Message

class StatusEffect(Component):
    def __init__(self, status_name, effect, duration):
        super().__init__('status_effect')
        self.status_name = status_name
        self.effect = effect
        self.duration = duration

class StatusEffects(Component):
    def __init__(self):
        super().__init__('status_effects')
        self.active_statuses = {}

    def add_status(self, status):
        self.active_statuses[status.status_name] = status

    def process_statuses(self):
        results = []

        to_delete = set()
        for name, status in self.active_statuses.items():
            if status.duration == 0:
                to_delete.add(name)
            else:
                status.duration -= 1
                results.extend(status.effect(self.owner))

        for name in to_delete:
            del self.active_statuses[name]
            results.append({'message': Message("{0} wore off.".format(name))})

        return results


class HealOverTime(StatusEffect):
    def __init__(self, status_name, amount, duration):
        def effect(target):
            target.fighter.heal(amount)
            return []
        super().__init__(status_name, effect, duration)

I decided I wouldn’t allow multiple effects by the same name; if there’s more than one at a time, the newest one “wins” and ends the older one early. This means you can’t drink 4 potions to heal four times as fast, and also prevents getting wrecked out of nowhere by multiple simultaneous wraiths.

Putting these effects in the game was surprisingly hard. Currently, the main turn loop works by repeatedly requesting the player’s input and then changing the game state based on what it is (where the game state can include things like looking at the character sheet or taking other actions that don’t eat a turn).

I can’t run the status effects in that loop or we end up with an exploit where the player can just open their character sheet over and over until the healing potion wears off. I also can’t just shove it in the enemy turn logic because that includes a loop over every game entity; it would mean that if there are four monsters, the potion would heal four times as each monster took its turn. I also didn’t want to wire something up where the monsters go before potion effects as it feels janky to have the player drink a potion, get hit by a monster, then start healing. So instead I ended up putting status effects in their own for loop at the beginning of the enemy turn. (There was a bug here but I didn’t notice until implementing the wraith. I’ll come back to this.)

Now to try applying one of those status effects. First we’ll create a use function similar to the potion function already in the game:

def regenerate(*args, **kwargs):
    entity = args[0]
    name = kwargs.get('name')
    amount = kwargs.get('amount')
    duration = kwargs.get('duration')

    results = []

    results.append({'consumed': True, 'message': Message('You feel a warmth pass over you.', libtcod.green)})
    entity.status_effects.add_status(status_effects.HealOverTime(name, amount, duration))
    return results

From there we just change the code that generates the healing potions in game_map.py:

if item_choice == 'rejuvenation_potion':
    item = Entity(x, y, '!', libtcod.desaturated_blue, "Potion of Rejuvenation", render_order=RenderOrder.ITEM)
    Item(use_function=regenerate, amount=10, duration=4).add_to_entity(item) 

Everything works exactly as intended, except the game crashes if you try to save.

Wait, What?

AttributeError: Can't pickle local object 'HealOverTime.__init__.<locals>.effect'

Oh, I get it. I’m glad I knew quite a bit of python before starting this tutorial. For those who didn’t: pickle is the module that shelve uses to save data. It’s complaining that it can’t save effect, a function I defined in HealOverTime‘s __init__ method. pickle isn’t up to the task of saving an arbitrary function that was only created at runtime (effect is defined in __init__ and doesn’t exist until __init__ actually runs).

There’s another library called dill that may be up to the task, but instead I found an even easier option. You can define custom __setstate__ and __getstate__ methods for pickle‘s benefit. So, how could we represent the state of a partially-completed heal-over-time effect as a Python dictionary? That’s actually pretty easy. Say a player drinks a potion, heals the first 10 hp of it, then exits. Then we want to save and quit the game. When the player reloads, we can just give them a new 3-turn heal over time effect at 10 points/turn. This is almost as easy to write in Python as it is in English:

def __getstate__(self):
    return {'status_name': self.status_name, 'amount': self.amount, 'duration': self.duration}

def __setstate__(self, state):
    self.__init__(state['status_name'], state['amount'], state['duration']) 

It turns out we also have to add self.amount = amount to the initializer to make that work, but otherwise everything goes without a hitch.

This also gave me the opportunity to fix something else that was bugging me. The tutorial’s saving code didn’t actually work on my machine. I suspect, though do not know for sure, that the difference is OS-specific. While poking around the shelve documentation to fix my crashing bug, I also tweaked the saving/loading code slightly so that it no longer cares about the exact filename used for saved games. I hope this version (in the github repo) is more portable.

Damage Over Time

Given what we’ve already done at this point, this is barely any effort.

class DamageOverTime(StatusEffect):
    def __init__(self, status_name, amount, duration):
        self.amount = amount
        def effect(target):
            # unlike healing, take_damage returns results
            return target.fighter.take_damage(amount)
        
        super().__init__(status_name, effect, duration)

    def __getstate__(self):
        return {'status_name': self.status_name, 'amount': self.amount, 'duration': self.duration}

    def __setstate__(self, state):
        self.__init__(state['status_name'], state['amount'], state['duration'])

Finally, the Wraith

The Wraith class itself isn’t much:

 class Wraith(Monster):
    def __init__(self, x, y):
        super().__init__(x, y, 'w', libtcod.han, 'Wraith', blocks=False, render_order=RenderOrder.ACTOR)

        Fighter(hp=1, defense=0, power=0, xp=50).add_to_entity(self)
        ai.WraithMonster().add_to_entity(self) 

The real work is in the AI:

class WraithMonster(Component):
    def __init__(self):
        super().__init__('ai')
        self.player_spotted = False

    def take_turn(self, target, fov_map, game_map, entities):
        results = []
        monster = self.owner

        # Return without doing anything until it spots the player for the first time
        if not self.player_spotted and not libtcod.map_is_in_fov(fov_map, monster.x, monster.y):
            return results

        self.player_spotted = True
        self.owner.move_towards(target.x, target.y, game_map, entities, ignore_blocking=True)

        if monster.distance_to(target) == 0:
            results.append({'message': Message("The wraith has haunted you!")})
            target.status_effects.add_status(status_effects.DamageOverTime('Haunted by Wraith', 5, 10))
            results.extend(monster.fighter.take_damage(1))
        
        return results

I’ll mostly skip the silly bugs on this one, though it was pretty funny when I didn’t think to have the wraiths wait before following the player. You’d sometimes start a floor and have three or four previously-unseen wraiths pop into the room all at the same time.

This is too spooky for me. That’s not a joke; my character’s going to die of HAUNTED.

There is one bug worth discussing because it was in the original tutorial code. It turns out that if move_towards tries to check the distance between two objects in the same location, it throws a ZeroDivisionError. It turns out this same function was the easiest way to implement creatures that can walk through walls by adding a new parameter:

     def move_towards(self, target_x, target_y, game_map, entities, ignore_blocking=False):
        dx = target_x - self.x
        dy = target_y - self.y
        distance = math.sqrt(dx ** 2 + dy ** 2)

        dx = int(round(dx / distance)) if distance != 0 else 0
        dy = int(round(dy / distance)) if distance != 0 else 0

        if ignore_blocking or not (game_map.is_blocked(self.x + dx, self.y + dy) or get_blocking_entities_at_location(entities, self.x + dx, self.y + dy)):
            self.move(dx, dy)

Now would be a good time to go back to that bug I talked about in the main game loop. It turns out that the tutorial version of the game only checks for a dead player when a monster attacks. This meant that going to zero HP from wraith damage let the player run around with a zero or negative HP total and continue playing the game.

A real adventurer doesn’t die, even when he’s killed!

I dislike the code I wrote to handle this. Enough that I’m not going to post it here (it’s in the Github repo if you really want to see it). I’m still deciding on what less-kludgy way I would prefer to do this.

Ghosts Don’t Leave Corpses

So, now we have wraiths that mostly work except they’re leaving dead ghost bodies. It turns out that the function that kills monsters also has all the corpse logic built in. Even worse, it doesn’t actually delete the monster, but replace its attributes with the corpse attributes. I decided that for now I’d settle for making dead wraiths “invisible” so they don’t render.

So first step, factor that stuff out and put it in the Monster class:

 class Monster(Entity):
    def __init__(self, x, y, char, color, name, blocks=True, render_order=RenderOrder.ACTOR):
        super().__init__(x, y, char, color, name, blocks, render_order)

    def set_corpse(self):
        self.char = '%'
        self.color = libtcod.dark_red
        self.blocks = False
        self.fighter = None
        self.ai = None
        self.name = 'remains of ' + self.name
        self.render_order = RenderOrder.CORPSE

Next step, immediately go back to Wraith and override it:

    def set_corpse(self):
        self.blocks = False
        self.fighter = None
        self.ai = None
        self.name = ''
        self.render_order = RenderOrder.INVISIBLE

And making RenderOrder.INVISIBLE a thing just requires a couple small tweaks to the renderer:

 class RenderOrder(Enum):
    INVISIBLE = auto()
    STAIRS = auto()
    CORPSE = auto()
    ITEM = auto()
    ACTOR = auto()

# intervening code omitted

# Draw all entities in the list
visible_entities = [e for e in entities if e.render_order != RenderOrder.INVISIBLE]
entities_in_render_order = sorted(visible_entities, key=lambda x: x.render_order.value)

Whew!

That was a lot of work for (mostly) one monster, but I’m really happy at the groundwork it laid for later things. I may want other creatures that can ignore walls, or leave different/no corpses, or other temporary statuses I can throw around, or spawn monsters outside of the level generation function.

In case you somehow missed it, I’ve put the whole thing on GitHub.

2 thoughts on “Roguelike Tutorial Week 3: The Wraith

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s