Roguelike Tutorial: Back on the Horse

Last week I didn’t get much done but set some goals for myself. How did that turn out?

Goal 1: Jazz Up the Escape

I ended up going with the Necromancer idea. I’m mostly happy with how it turned out. The biggest blunder was not realizing that if the Necromancer’s Skeletons dropped corpses like most monsters, then the Necromancer could keep raising them from the dead forever. Luckily I already laid the groundwork to fix this when I decided the Wraith would not leave a corpse so this took a couple of minutes.

The primary difficulty was writing code to make the Necromancer do something interesting. Given the player already knows the way back to the entrance, it’s easy to imagine a Necromancer spawning a bunch of skeletons in a room where the player will never actually go. As it stands, the Necromancer starts at the entrance and runs around raising skeletons until he meets the player, which is about as optimal as it gets for spawning monsters in a location that the player will care about. Getting the order of the Necromancer’s rules right was a bit more difficult than it looks and may turn into an essay on its own after I’m done with the game:

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

        nearest_corpse = game_map.find_entity(lambda e: e.char == '%', lambda e: e.distance_to(monster))

        if libtcod.map_is_in_fov(fov_map, monster.x, monster.y):
            results.extend(monster.fighter.attack(target))
        elif not nearest_corpse:
            # hunt player if no corpses on the floor
            monster.move_astar(target, game_map, max_path=None)
        elif monster.distance_to(nearest_corpse) <= 3:
            # use nearest corpse to spawn a skeleton
            x, y = nearest_corpse.x, nearest_corpse.y
            game_map.entities.remove(nearest_corpse)
            game_map.entities.append(monsters.Skeleton(x, y))
        else:
            # hunt down the nearest corpse to reanimate
            monster.move_astar(nearest_corpse, game_map, max_path=None)

        return results

This all also drove me to write a general method to find an arbitrary type of entity through the DungeonFloor class. I would rate this code snippet “most likely to be useful for people writing their own variants of this tutorial game”:

def find_entity(self, condition, key=None):
    entities_list = sorted((e for e in self.entities if condition(e)), key=key)
    return entities_list[0] if entities_list else None

This stands a decent chance of looking like complete nonsense to anyone who learned Python specifically for this tutorial, so here’s an example use case:

class UpStairsExit(Exit):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.taken_with_chalice = False
    
    def take_exit(self, player, message_log):        
        if not self.taken_with_chalice and any(item.name == 'Magic Chalice' for item in player.inventory.items):
            self.taken_with_chalice = True
            next_upstairs = self.next_floor.find_entity(lambda e: e.name == "Stairs (Up)")
            if next_upstairs:
                self.next_floor.entities.append(monsters.Necromancer(next_upstairs.x, next_upstairs.y))
        return super().take_exit(player, message_log)

The relevant bit comes when setting next_upstairs; I need to know where the upward staircase is so I know where to spawn the Necromancer. I didn’t specify a key because I know there’s only going to be one upward staircase per floor. The key comes into play when the Necromancer’s AI tries to find the nearest available corpse to resurrect:

nearest_corpse = game_map.find_entity(lambda e: e.char == '%', lambda e: e.distance_to(monster))

The end of all of this is a boss that can sometimes be evaded, but rarely can he be evaded easily. I may have to tone it down in some ways but overall I like how this one plays.

Status: Complete

Goal 2: Combat

I ended up implementing the d20 combat ripoff I talked about. I don’t like where the balance is right now. In fact, I was having so much trouble beating the game that I removed the Balrog, previously the hardest monster.

It turns out that the game gets a lot harder when you can no longer become invincible by stacking damage mitigation. It’s also a bit screwy now that the offensive and defensive stat on levelup no longer have symmetrical-looking numbers. They were grossly asymmetrical in powerlevel before, but now they’re aesthetically weird, which may be even worse to my tastes?

I did what I set out to do, but I’m not convinced the result is an improvement. But I was bored with where it was before. But I expressly told myself that I was aiming for the finish line here. I don’t know what I want here.

Status: Oh god what am I doing with my life

Goal 3: Distribution

Didn’t even find the time to look at this one, really. Probably going to write this one off. I know just enough about getting Python to run on other people’s computers to know that I don’t actually know how to do it, and I already have enough going on to fill what remaining time I’ve given myself to work on this project.

Status: WONTFIX

The most recent version of the codebase is available in the usual place.

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