Let's code with the Roguelike tutorial - Part 3 - Making a dungeon


Refactoring the map code

It's time to start making some rooms and connecting them together. These will require making modifications to the map, so I guess it's time to finally refactor the map code into its own legitimate class.

Most of the refactoring is straightforward... besides, of course, running into some problems with the Rust borrow checker. Originally, I wanted the map to own all the tiles, and getting a tile would involve borrowing a reference to the Tile object owned by the map. This could return mutable references, so to modify a tile, I would call a function to borrow a reference, modify the tile, and then close the scope to return the reference. This turned out to be surprisingly complicated, and although I think I could have probably gotten it to work, during the course of searching online to solve some issues, I ran into a surprising fact: it's not easy to borrow two mutable references at the same time. This means that even if I had gotten returning a reference to work, I probably would have run into quite a few problems later when I need to access multiple tiles for comparisons.

So what I ended up doing was instead returning a copy of the tiles. The tiles are fairly small, so although I'm not super happy with this performance, I think it's also ok, and if I need to optimize at the end, I can figure out how to restructure some of this. Of course, since now I'm returning copies, I also needed to add a function to set a tile.

It also occurred to me that I might be able to overload the square brackets to make tile access look more like array access, but that doesn't seem particularly useful for now, so I keep that as a possible enhancement for later.

Adding a room

Now that the refactoring is done, I can add a create_room method to Map.

The tutorial creates a full Rect class and passes that around as a parameter to create_room. That seems unnecessary to me. Instead, I passed the dimensions directly as parameters. If this were a big API with lots of different methods taking these same parameters, I might make a rect class, but for just a single function, that seems excessive.

But previously, when working with the game state, I said that I wanted to keep all the state together in single struct. But in this case, I'm proposing keeping them separate. What's the difference? Well in this case, the room dimensions aren't state. It's a value parameter that's immediately cracked by the create_room method. Having said that, it's a close decision, and small changes would tilt me back towards making a struct to hold the dimensions.

Creating a room is done directly by iterating through the dimensions, and marking each tile not blocked.

Similarly, creating the horizontal and vertical tunnels to connect the rooms, works the same way.

Generating a full dungeon

So as it turns out, making a full dungeon requires calculating the center of rooms and testing the intersection of rooms. So having a rect class as an abstraction does make sense after all.

However, I still think not using a rect class the first time wasn't a mistake. I suppose I could have read ahead in the tutorial and known I would eventually need this abstraction anyway. On the other hand, in real life programming, road maps and feature priorities can change constantly. And in the end, it really didn't cost me that much work anyway to skip making the rect class the first time.

I ported the rectangle code over from the Python tutorial and it looks pretty much the same - except with Rust syntax. With that in hand, I created a vector to store the list of rooms in the full dungeon.

To do the actual of the rooms, the Python tutorial has a loop which randomly generates a room, tests to see if it intersects with any previous rooms, and if it's the first room, it sets the player position to the room's center.

I used fundamentally the same loop, but with a few tweaks:

With all that, my code to generate a dungeon looks like:


for _ in 0..MAX_ROONS {
    let w = tcodrs::random_get_int(ROOM_MIN_SIZE, ROOM_MAX_SIZE);
    let h = tcodrs::random_get_int(ROOM_MIN_SIZE, ROOM_MAX_SIZE);
    let x = tcodrs::random_get_int(0, MAP_WIDTH - w - 1);
    let y = tcodrs::random_get_int(0, MAP_HEIGHT - h - 1);
    let new_room = Rect::new(x,y,w,h);

    // Check if new room intersects old rooms
    let mut failed = false;
    for other_room in &rooms {
        if new_room.intersect(other_room) {
            failed = true;
            break;
        }
    }

    if !failed {
        map.create_room(&new_room);
        let (new_x, new_y) = new_room.center();

        // If not the first room, connect the last room to this one
        if let Some(last_room) = rooms.last() {
            let (prev_x, prev_y) = last_room.center();

            if tcodrs::random_get_int(0, 1) == 1 {
                map.create_h_tunnel(prev_x, new_x, prev_y);
                map.create_v_tunnel(prev_y, new_y, new_x);
            } else {
                map.create_v_tunnel(prev_y, new_y, prev_x);
                map.create_h_tunnel(prev_x, new_x, new_y);
            }
        }

        rooms.push(new_room);
    }
}

// Set starting position
let (x,y) = rooms.first().unwrap().center();
map.startx = x;
map.starty = y;

Summary

With most of the basic structure of the program up and running, and now having a bit more experience with Rust, the implementations are starting to get a bit easier. In most cases, it comes down to:

  1. Decide what feature to add next (while following the tutorial, the features are basically given)
  2. Decide a good design for the new feature, and figure out what needs to be changed in the current design to accommodate. This means finding the right balance between "cleanliness" of the code and "directness."
  3. Refactor. Test to make sure there are no regressions.
  4. Add the new feature.

Most of this part was a fairly straightforward port of the Python version. Of course, the Rust borrow checker still springs a lot of surprises, but I think I'm starting to get an intuition of how the borrow checker works. It will probably stil take me a while until I can easily structure my code to work around the borrow checker though.

Besides that, the major other issue I have is the use of (row,column) vs (x,y). While I find (row,column) more intuitive, I'm making quite a few tcod calls, which means I have to keep making the conversion back and forth between the two systems. When I'm writing all the code myself, this isn't a problem, but in this case, it's leading to a frequently recurring impedance mismatch, which is a bit annoying.


In this series: Contents, previous, next


by Yosen
I'm a software engineer who works on both mobile and web programming. I think STEM education is important. I also follow pop culture and dabble in music and the digital arts.