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:
- I don't like the idea of a function that creates a map, to also update a player's starting position. This doesn't seem like the right separation of concerns. For example, what if I wanted to make multiple maps later. What I did instead is to save the starting position as a separate (x,y) ivar on the map. So when my main function calls the function to create a new random map, it can subsequently get its starting position and set the player's position.
- I also get rid of the if statement in the loop that checks whether it's the first iteration (by checking to see if the number of rooms is zero). The reason for this is a bit more fuzzy, but it has to do with the number of distinct paths that can be executed through the code. When there's an if statement, that doubles the number of possibilities. Now in this case, it's pretty trivial to see that the main branch will be executed only the first time through the loop, and the else branch executed all the other times. But this is the kind of thing that's easy to accidentally change during modifications. I replaced this with just a single test to see if there's a previous room. Then, outside the main loop, I grab the first room and retrieve its center. So now I'm sure that this code will be executed every time I create a map. The question is whether there will always be a first room. I think the answer here is yes, due to the way the random number generator works. I considered asserting here that I successfully retrieved a room, but Rust already has a way to access with a check, so I think an extra assert is extraneous.
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:
- Decide what feature to add next (while following the tutorial, the features are basically given)
- 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."
- Refactor. Test to make sure there are no regressions.
- 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