Tutorial Part 14: Open Sesame

Door Generation Bugfix

@Freiling: Doors on North & West of rooms generate correctly, but appear to generate 1 tile recessed into hallways on South & East.

Good catch! So the question is, what are we going to do about it? 70% of writing good code is being a good detective when things look janky… and janky door generation is a sign of janky mathematics. So how do we track down the culprit?

Let’s take a look at our MapGenerator class. My first guesses would be either IsPotentialDoor or CreateDoor since both of them are involved in door placement. Skim through IsPotentialDoor: do you see any places where a tile could potentially be shifted one space to the right or one space down?

There are several candidate that fit the description, like this:

//store references to all neighbouring cells
Point right = new Point(location.X + 1, location.Y);
Point left = new Point(location.X - 1, location.Y);
Point top = new Point(location.X, location.Y - 1);
Point bottom = new Point(location.X, location.Y + 1);

Those sure do look suspicious don’t they? Lots of places for potential for tile mis-placement.

Unfortunately, that was a red herring Dr. Watson. The culprit lay elsewhere, hiding in the shadows from two tutorials earlier, so it could strike when it was least expected!

The tile-shifter was CreateRoom, in the MapGenerator, with an Integer!

    // Builds a room composed of walls and floors using the supplied Rectangle
    // which determines its size and position on the map
    // Walls are placed at the perimeter of the room
    // Floors are placed in the interior area of the room
    private void CreateRoom(Rectangle room)
    {
        // Place floors in interior area
        for (int x = room.Left + 1; x < room.Right - 1; x++)
        {
            for (int y = room.Top + 1; y < room.Bottom - 1; y++)
            {
                CreateFloor(new Point(x,y));
            }
        }

        // Place walls at perimeter
        List perimeter = GetBorderCellLocations(room);
        foreach (Point location in perimeter)
        {
            CreateWall(location);
        }
    }

The intention was to create Walls around the outer perimeter of the room rectangle, and Floors on the inner perimeter. Unfortunately, the above code produces a floor that’s offset Right + 1 and Bottom + 1! We want the floor to be inset, not outside of the room. Update these lines:

for (int x = room.Left + 1; x < room.Right; x++)
{
     for (int y = room.Top + 1; y < room.Bottom; y++)

And now you have floors inset correctly, and doors positioned at the border of the room.

Opening Doors

Now that we’ve managed to lock away our avatar in a seclusion for almost a year, it’s about time we allowed it brief excursions into the frightening unknown. What good is a door that can’t open or close?

We already have the door open and close code written, but we haven’t glued it to the CommandSystem yet. If you recall our CommandSystem, it works (so far) as a central controller that lets Actors do things on the Map, like begin combat with other Actors, or grab Items. In this case, we’re going to create a custom Command that controls how Actors can use TileDoor. We’ll start by letting Actors open doors, and then get fancier after that.

Open the CommandSystem class, and add a new Using directive so we can access the Tiles namespace:

using SadConsoleRLTutorial.Tiles;

Now add a new method called UseDoor. UseDoor can be called by any Actor when it steps into a TileDoor’s space.

    // Triggered when an Actor attempts to move into a doorway.
    // A closed door opens when used by an Actor.
    public void UseDoor(Actor actor, TileDoor door)
    {
        // Handle a locked door
        if (door.Locked)
        {
            // We have no way of opening a locked door for the time being.
        }
        // Handled an unlocked door that is closed
        else if (!door.Locked && !door.IsOpen)
        {
            door.Open();
            GameLoop.UIManager.MessageLog.Add($"{actor.Name} opened a {door.Name}");
        }
    }

Now it’s a case of allowing the Actor access to the UseDoor command we just added. Open your Actor class and modify it in the same way we previously handled monsters and items:

    // Moves the Actor BY positionChange tiles in any X/Y direction
    // returns true if actor was able to move, false if failed to move
    // Checks for Monsters, Items and Doors before moving
    // and allows the Actor to commit an action if one is present.
    public bool MoveBy(Point positionChange)
    {
        // Check the current map if we can move to this new position
        if (GameLoop.World.CurrentMap.IsTileWalkable(Position + positionChange))
        {
            // if there's a monster here,
            // do a bump attack
            Monster monster = GameLoop.World.CurrentMap.GetEntityAt(Position + positionChange);
            Item item = GameLoop.World.CurrentMap.GetEntityAt(Position + positionChange);
            TileDoor door = GameLoop.World.CurrentMap.GetTileAt(Position + positionChange);
            if (monster != null)
            {
                GameLoop.CommandManager.Attack(this, monster);
                return true;
            }
            // if there's an item here,
            // try to pick it up
            else if (item != null)
            {
                GameLoop.CommandManager.Pickup(this, item);
                return true;
            }
            // if there's a door here,
            // try to use it
            else if (door != null)
                GameLoop.CommandManager.UseDoor(this, door);

            Position += positionChange;
            return true;
        }
        else
            return false;
    }

Your IDE is likely barfing an error at you now: GetTileAt<TileDoor>() does not accept a Point as a parameter. Not to worry, with some glorious copypasta we can create a second GetTileAt method that accepts Point as a parameter (instead of having to send X and Y coordinates). (Thanks @Chris3606 for suggesting some cleaner code than I had originally put here!) Modify your Map class by adding a second GetTileAt method below the original one:

    // Checks if a specific type of tile at a specified location
    // is on the map. If it exists, returns that Tile
    // This form of the method accepts a Point coordinate.
    public T GetTileAt(Point location) where T : TileBase
    {
        return GetTileAt<T>(location.X, location.Y);
    }

Why does the compiler allow two nearly identical methods? It’s because most C-like compilers allow for methods with different signatures. A method’s signature tells the compiler how that method is different from other methods. In this case, GetTileAt<T>(Point location) gets its signature from its name (GetTileAt) and the type of parameter it accepts. So if we have two methods with the same name, but have different parameter types, the compiler sees these as two completely different methods. Overloading a method name so it can accept different types of parameters kicks butt because it simplifies (and reduces the amount of) code elsewhere in your project. This will keep our Actor class cleaner and easier to read.

Compile your project and run it. Your avatar should be able to walk up to doors and op–

— oh FFS. It didn’t work. Why?

Let’s go back to our Actor class and see if we can trace out why TileDoor is not opening:

        // Check the current map if we can move to this new position
        if (GameLoop.World.CurrentMap.IsTileWalkable(Position + positionChange))
        {
            ...
            // if there's a door here,
            // try to use it
            else if (door != null)
                GameLoop.CommandManager.UseDoor(this, door);

            Position += positionChange;
            return true;
        }

Ah…….. woops. Do you see the problem? If not, don’t worry – neither did I when I first wrote the code, or we wouldn’t be in this mess 🙂 It’s the first if statement that checks if the Tile is Walkable: since TileDoor is not walkable, it completely skips over our nice door opening code. We’ll have to refactor this a bit, and take into consideration how to handle tiles that are not walkable, yet are usable:

    // Moves the Actor BY positionChange tiles in any X/Y direction
    // returns true if actor was able to move, false if failed to move
    // Checks for Monsters, and Items before moving
    // and allows the Actor to commit an action if one is present.
    public bool MoveBy(Point positionChange)
    {
        // Check the current map if we can move to this new position
        if (GameLoop.World.CurrentMap.IsTileWalkable(Position + positionChange))
        {
            // if there's a monster here,
            // do a bump attack
            Monster monster = GameLoop.World.CurrentMap.GetEntityAt<Monster>(Position + positionChange);
            Item item = GameLoop.World.CurrentMap.GetEntityAt<Item>(Position + positionChange);
            if (monster != null)
            {
                GameLoop.CommandManager.Attack(this, monster);
                return true;
            }
            // if there's an item here,
            // try to pick it up
            else if (item != null)
            {
                GameLoop.CommandManager.Pickup(this, item);
                return true;
            }

            Position += positionChange;
            return true;
        }
        // Handle situations where there are non-walkable tiles that CAN be used
        else
        {
            // Check for the presence of a door
            TileDoor door = GameLoop.World.CurrentMap.GetTileAt<TileDoor>(Position + positionChange);
            // if there's a door here,
            // try to use it
            if (door != null)
            {
                GameLoop.CommandManager.UseDoor(this, door);
                return true;
            }
            return false;
        }
    }

It’s not the prettiest code, but it gets the job done for now. So the if statement is basically broken into two halves: one half handles walkable tiles that Items and Monsters are standing on, and the other half handles non-walkable tiles that we can use like TileDoor. Run your project and voila, we have opening doors!

One thing to consider in the above code is that it contains some gameplay logic: opening a door is treated as one move. Moving into a closed door only opens it, it does not open it and move the avatar at the same time. Some Roguelikes allow the player to open a door and move into it at the same time. If you want that style of gameplay, you can easily modify the above code to suit your preference. You can do this by adding Position += positionChange; between the UseDoor call and return true.

In the next tutorial, we’ll figure out how to create locks and keys, and turn our little series of rooms into a truly inhumane dungeon of misery.

Download the completed source files for the tutorial here.

Published on May 23, 2020