The Room Do I dare disturb the universe?

A Roguelike in Perl Part 6 - Doing (and taking) some damage

Introduction

This post is part of a series of blog posts following the roguelike tutorial to demonstrate the new class feature in Perl 5.38.0.

In this post we’re gonna start where we left off in part 5. If you don’t remember what the code looked like you can refresh yourself with the listing here.

Refactoring

When we ended last time we noted that we probably would need to refactor the action. Let’s look at the current implementation:

my sub movement_action($dx, $dy) {
    my ($x, $y) = ($player->x + $dx, $player->y + $dy);
    return unless $map->is_in_bounds($x, $y);
    return unless $map->tile_at($x, $y)->is_walkable;
    if (my $e = $map->has_entity_at($x, $y)) {
        if ($e->blocks_movement) {
            say sprintf('The %s gives the %s a swift kick.', $player->name, $e->name);
            return;
        }
    }
    $player->move($dx,$dy);
}

It’s a simple lexical subroutine, and that was great when there was a single kind of action, but now that we have potentially several kinds of actions and actions that potentially spawn new actions, we’re gonna need something a bit more complex. Time for an Action class.

Let’s create a new module Actions.pm and add the following to it.

use 5.38.0;
use warnings;
use experimental 'class';

class Action {
    method perform() { ... }
}

The first class we implement Action defines a single method perform() that simply dies with Unimplemented if you were to execute it. How is this at all useful you may be asking yourself? Well by itself it wouldn’t be, but what it does is creates an API that we can build upon.

Let’s add another class to our Actions file:

class MovementAction :isa(Action) { }

The MovementAction does something we haven’t needed to do yet in this tutorial, it inherits from the Action class via the :isa(Action) attribute in the class declaration. This means that anywhere someone has a MovementAction object they can call methods declared in Action and if they’re not also declared in MovementAction then it will look up the method in Action class and call it instead, on the same object.

Inheritance is tricky and difficult to use without running into problems in places, normally I would advise against using it if possible. The current implementation of the class feature though doesn’t have a lot of other options available yet and this is as good as any to demonstrate how to use it, and to talk about some of it’s pitfalls.

First lets expand our MovementAction class a bit:

class MovementAction :isa(Action) {
    field $dx :param = 0;
    field $dy :param = 0;

    field $map :param;

    method perform() {
        my ( $x, $y ) = ( $player->x + $dx, $player->y + $dy );
        return unless $map->is_in_bounds( $x, $y );
        return unless $map->tile_at( $x, $y )->is_walkable;
        if ( my $e = $map->has_entity_at( $x, $y ) ) {
            return if $e->blocks_movement;
        }
        $player->move( $dx, $dy );
    }
}

The perform() method should be familiar, it’s a direct copy of the movement_action function from our Engine class, except instead of the $dx and $dy parameters being provided to the function they’re provided as fields on the MovementAction class. If we were to run this like it is though we would have a problem, $player isn’t defined.

Every Action should have an entity that performs that action so we can easily just modify the Action class like so

class Action {
    field $entity :param;
    method perform() { ... }
}

But with the current implementation of feature class you cannot inherit state from your parent. There are good reasons for this that basically are summarized with “it can easily lead to nasty bugs that look like they’re what you want but in practice break in ways you don’t expect.” So we will need to provide a way for the MovementAction to get access to that data.

class Action {
    field $entity :param;

    method entity {
        die 'protected method' unless caller()->isa(__PACKAGE__);
        return $entity
    }

    method perform() { ... }
}

Unfortunately we don’t have a way to explicitly limit methods to only be visible by our sub-classes. This is a feature that is currently under discussion so we’re gonna have to fake it. We write a normal reader method like we have a dozen times before but we add a check this time to make sure that the caller inherits from the current package: die 'protected method' unless caller()->isa(__PACKAGE__); The new feature class doesn’t replace the existing perl Object System it has just extended it in some select ways to make it easier and more natural based on developments that have happened since 1995.

Now in our MovementAction class’s perform() method we can add one line:

method perform() {
    my $player = $self->entity();
    my ( $x, $y ) = ( $player->x + $dx, $player->y + $dy );
    return unless $map->is_in_bounds( $x, $y );
    return unless $map->tile_at( $x, $y )->is_walkable;
    if ( my $e = $map->has_entity_at( $x, $y ) ) {
        return if $e->blocks_movement;
    }
    $player->move( $dx, $dy );
}

Before we forget, we’ll also need a QuitAction class. Thankfully this one is pretty simple.

class QuitAction :isa(Action) {
    method perform() { exit }
}

Our code should now be ready to be hooked into the engine, before sure to load the new Actions module.

use Entities;
use Actions;

Then update the ADJUST block to use the MovementAction class:

ADJUST {
    $app->add_event_handler(
        'keydown' => sub ($event) {
            my %KEY_MAP = (
                h => MovementAction->new(
                    map    => $map,
                    entity => $player,
                    dx     => -1
                ),
                j => MovementAction->new(
                    map    => $map,
                    entity => $player,
                    dy     => 1
                ),
                k => MovementAction->new(
                    map    => $map,
                    entity => $player,
                    dy     => -1
                ),
                l => MovementAction->new(
                    map    => $map,
                    entity => $player,
                    dx     => 1
                ),
                q => QuitAction->new(
                    entity => $player
                ),
            );

            # lets execute the action now
            $KEY_MAP{ $event->key }->perform();
        }
    );
    $app->run( sub { $self->render() } );
}

One last minor thing before we get started. We added a param blocks_moement to the Entity class, and had it default to 0. This really seems backwards, like most of our entities are going to be corporeal and block movement, we should make that the default and override it for ghosts and what not.

field $blocks_movement :param //= 0;

Slightly more questionably I’m going to set the default X and Y coordinates to 0 as well.

field $x :param //= 0;
field $y :param //= 0;

This will allow us to simplify our Entity creation methods a little as well.

package Entities {

    sub goblin() {
        Entity->new(
            char => 'g',
            name => 'goblin',
            fg   => '#41924B',
        )
    }

    sub hobgoblin() {
        Entity->new(
            char => 'h',
            name => 'hobgoblin',
            fg => '#ff6f3c',
        )
    }

    sub player() {
        Entity->new(
            char => '@',
            fg   => '#fff',
            name => 'hero',
        );
    }

Onwards to Part 6

So we left off in part 5 ready for combat but hadn’t really implemented anything yet. Let’s actually get ready to rumble.

First we’ll need to sort out some rules for combat. I’m going to be borrowing from Ben Milton’s Knave. (Specifically I’m borrowing from the 1.0 version that you can find on archive.org.1)

A melee attack is resolved by having the attacker roll a 1d20 and adding their Strength bonus and the defender rolling a 1d20 and adding their Armor bonus. If the attacker rolls higher than the defender, they hit. Damage is calculated by taking the difference of the two rolls.2

So we’re going to need some abilities: Strength, Armor, and Hit Points. Let’s add a new class to our Entities module.

class Abilities {
    use List::Util qw(min);

    field $strength :param;
    field $armor    :param //= 1;
    field $max_hp   :param(hp);
    field $hp = $max_hp;

    method armor()    { $armor }
    method strength() { $strength }
    method hp()       { $hp }
    method change_hp($delta) { $hp += $delta }
}

Let’s walk through the nuance of this a little. Our $strength score must be passed in. Our $armor defaults to a +1 bonus, and we have to explicitly set our max HP. We have a separate hp field to track the current HP for the entity, but that is entirely internal so we’re free to alias the max_hp field’s constructor arg to just hp.

We need to update the Entity class to hold these new Abilities:

class Entity {
    field $x :param //= 0;
    field $y :param //= 0;
    field $char: param;
    field $fg :param //= '#fff';
    field $bg :param //= '#000';
    field $name :param //= "<unnamed>";
    field $blocks_movement :param //= 1;
    field $abilities :param;

    method x { $x }
    method y { $y }
    method char { $char }
    method fg { $fg }
    method bg { $bg }
    method blocks_movement { $blocks_movement }
    method stats { $abilities }

    method move($dx, $dy) {
        $x += $dx;
        $y += $dy;
    }
}

Let’s modify our entity factories to populate these new Abilities:

package Entities {
    use List::Util qw(min);
    use Games::Dice qw(roll roll_array);

    sub goblin() {
        Entity->new(
            char => 'g',
            name => 'goblin',
            fg   => '#41924B',
            abilities => Abilities->new(
                strength => -1,
                armor    => 0,
                hp       => roll('1d8-1'),
            )
        );
    }

    sub hobgoblin() {
        Entity->new(
            char => 'h',
            name => 'hobgoblin',
            fg => '#ff6f3c',
            abilities => Abilities->new(
                strength => 1,
                armor    => 1,
                hp       => roll('1d8+1'),
            ),
        );
    }

    sub player() {
        Entity->new(
            char => '@',
            fg   => '#fff',
            name => 'hero',
            abilities => Abilities->new(
                strength => min(roll_array('3d6')),
                armor    => 1,
                hp       => roll('1d8'),  # TODO add CON bonus
            ),
        );
    }
}

Rather than write our own dice rolling library let’s install one off the CPAN. Running cpanm Games::Dice should work to install the library. We’re going to use it to populate some stats for our entities.

Goblins according to the Monster Manual are slightly weaker than humans, so they have a -1 strength bonus, their armor is minimal at best so 0 bonus there, and their hit points are just a little lower than a human.

Hobgoblins on the other hand are about even, if not a little stronger than the average human, so they get a strength bonus of 1, and an armor bonus of 1, and do slightly better than normal on hit points.

Our player character is a bit more randomized. Knave says to roll 3 6 sided dice and take the lowest one for an attribute so that’s what we do for our strength, we take the minimum of 3 d6 dice. We don’t have equipment yet so our armor is the standard value of 1, and our HP is a standard roll, eventually we’ll need to figure out constitution to handel healing and Knave says to add that to our HP roll so we’ve left ourselves a note to remember that.

If we run thigns now … nothing has changed. Not surprising because we haven’t actually hooked up the combat rolls. Let us go ahead and do that now.

We start with a new MeleeAttackAction class in our Actions module.

class MeleeAttackAction :isa(Action) {
    use Games::Dice qw(roll);

    field $defender :param;

    method perform() {
        my $attacker = $self->entity;
        my $attack_roll = roll('1d20') + $attacker->stats->attack;
        my $defense_roll = roll('1d20') + $defender->stats->armor;

        if ($attack_roll > $defense_roll) {
            $defender->stats->change_hp($defense_roll - $attack_roll)
        }
        return;
    }
}

Here we have our $entity as our $attacker and a take a $defender as required parameter. We have each of them roll and we compare the results. If the attack roll is higher than the defense roll … the attack succeeds and we update the defender’s hit points with the difference as damage.

Next we update our MovementAction code so that when we bump into an enemy we attack it instead.

if ( my $e = $map->has_entity_at( $x, $y ) ) {
    $combat = MeleeAttackAction->new(
        attacker => $player,
        defender => $e,
    );
    return $combat->perform();
}

Now if we run things we should be able to attack our enenmies … but they don’t actually die. We need to do something about that.

Let’s update the MeleeAttackAction a little to check for if we’ve killed the defender.

class MeleeAttackAction :isa(Action) {
    use Games::Dice qw(roll);

    field $defender :param;
    field $map :param;

    method perform() {
        my $attacker = $self->entity;
        my $attack_roll = roll('1d20') + $attacker->stats->attack;
        my $defense_roll = roll('1d20') + $defender->stats->armor;

        if ($attack_roll > $defense_roll) {
            $defender->stats->change_hp($defense_roll - $attack_roll)
            if ($defender->stats->hp <= 0) {
                $map->remove_entity($defender);
            }
        }

        return;
    }
}

$defender->stats->change_hp( $defense_roll - $attack_roll );

We need to update the call in our MovementAction code to pass in the map:

if ( my $e = $map->has_entity_at( $x, $y ) ) {
    $combat = MeleeAttackAction->new(
        map      => $map,
        attacker => $player,
        defender => $e,
    );
    return $combat->perform();
}

We’ll also need to add the remove_entity method to the GameMap:

method spawn($e, $x, $y) {
    $e->move($x, $y);
    push @$entities, $e;
}

method remove_entity($e) {
    $entities = [grep { $_ != $e } @$entities];
}

Now when we do enough damage to a monster it dies and disappears. These fights seem kind of one sided though. The monsters aren’t really attacking back. Let’s give them some motivation of their own.

First let’s write a new class in Entities for all our “mobile” NPCs (aka Mobs)

class Mob :isa(Entity) {
    method next_action($map) {
        say STDERR $self->name . ' thinks about what to do next.';
    }
}

This subclass allows us to have Entities that come up with their own next actions. We can update the goblin and hobgoblin functions to use this new subclass.

sub goblin() {
    Mob->new(
        name      => 'goblin',
        char      => 'g',
        fg        => '#41924B',
        abilities => Abilities->new(
            strength => -3,
            armor    => 0,
            hp       => roll('1d8-1'),
        )
    );
}

sub hobgoblin() {
    Mob->new(
        name      => 'hobgoblin',
        char      => 'h',
        fg        => '#ff6f3c',
        abilities => Abilities->new(
            strength => -1,
            armor    => 1,
            hp       => roll('1d8+1'),
        ),
    );
}

We should also update the GameMap with a function that runs through the Mobs and performs their next actions.

method remove_entity($e) {
    $entities = [grep { $_ != $e } @$entities];
}

method update_entities() {
    for my $mob ( grep { $_ isa Mob } @$entities ) {
        my $action = $mob->next_action($self);
        next unless $action;

        $action->perform();
    }
}

And we need to trigger this update somewhere, let’s do it right after we make a move in the Engine.

# lets execute the action now
$KEY_MAP{ $event->key }->perform();

# now everyone else gets a turn
$map->update_entities();

So far so good. Our mobs now get a turn to perform an action. For maximum excitement we want them to chase us down and attack us so let’s take the pieces we have and see what we can do:

method next_action($map) {
    state $fov = Games::ROT::FOV->new();
    my @visible = $fov->calc_visible_cells_from(
        $self->position->@*,
        8,
        sub ($cell) { $map->tile_at(@$cell)->is_opaque() },
    );

    my @entities = grep { defined } map { $map->has_entity_at( @$_ ) } @visible;
    my $player = first { $_->char eq '@' } @entities;
    return unless $player;

    my @step = Games::ROT::AStar::get_path( $map, $self->position,
        $player->position );

    return unless @step;

    return MovementAction->new(
        entity => $self,
        map    => $map,
        dx     => $step[1]->[0] - $self->x,
        dy     => $step[1]->[1] - $self->y,
    );
}

This is a lot but let’s break it down. First we use same FOV code that we use for the player, to grab all the tiles the Mob can see. We turn that into a list of entities, and try to grab the player character out of it (the player is the one that looks like an @). If the mob can’t see a player, don’t do anything.

Next we use a new function Games::ROT::AStar’s get_path() to give us a path to the player. The A* pathfinding algorithm is very well known and considered a pretty solid choice, and it happens to be the one that Games::ROT implements. If you’ve been following along, you’ll need to update Games::ROT to the latest version: cpanm https://github.com/perigrin/perl-games-rot.git should work.

If the mob can’t find a path to the player, bail. Otherwise, create a new MovementAction to take the mob one step closer to the player. Remember that if the next step is into the player our mob automatically attack them using the exact same combat code used for the player to attack the mobs.

This gives us monsters who chase us and attack us unless we get to them first.

Hmm, you may have noticed that the monsters can move diagonally while the player cannot. Let’s fix that quickly, add the following to the KEY_MAP in the engine:

y => MovementAction->new(
    map    => $map,
    entity => $player,
    dx     => -1,
    dy     => -1,
),
u => MovementAction->new(
    map    => $map,
    entity => $player,
    dx     => 1,
    dy     => -1,
),
b => MovementAction->new(
    map    => $map,
    entity => $player,
    dx     => -1,
    dy     => 1,
),
n => MovementAction->new(
    map    => $map,
    entity => $player,
    dx     => 1,
    dy     => 1,
),

With that we have a working, albeit exceptionally basic adventure. Next time we’ll explore how to add some polish so we can see what is happening in a bit more detail.

Last weeks were the last tutorials I had pre-written so things may slow down a little as I will have to catch back up and try to write two a week as well as the code to go with them.

Our code has gotten too big to easily just show it all below. If you’d like to see the full code listing you can check here.

  1. Knave 2e is just finishing a successful kickstarter 

  2. Damage isn’t calculated this way in Knave but it is in Maze Rats, and in Cairn and it makes life easier when we don’t have equipment for another week.