Field of view
In the current implementation, the player can see the entire layout of the dungeon from the beginning. The next step is to change the game to start off with the dungeon hidden, so the player will actually have some rooms to explore.
The libtcod library already has functions for handling the field of view, so most of this task was spent building the right bridging interface in Rust.
First, there is an enum for the different FOV algorithms. Having done this several times for this project now, the bridge is straightforward.
Next, there's a bunch of functions that work with a tcod map. How should I bridge these from the Rust code?
- I could just add the calls to my current Map class. This brings up an interesting discussion about whether the Map class should be specific for this game, or more of a generic kind of class. If it's specific, then having a bunch of unsafe calls to make tcod calls - which are a bit of an implementation detail - doesn't really make sense. If it's a generic kind of class, then maybe it should get moved into the tcodrs module instead of part of the main game.
- I could have the Map class call out to some tcodrs modules, which then subsequently call out to libtcod. This is similar to the previous design, except it tries to be a bit more explicit about keeping the libtcod details away from the main code.
- I could have the main program handle the bridge. In this design, every time the main program made a call to my Map class (for example, to set a tile), it would make the corresponding call to libtcod to keep the library's view of the map consistent.
This is somewhat of a close decision. I think any of these could be reasonable - although I do think the third option is a bit more distant, and I would want someone to justify why they wanted to design it that way. It could still be the right decision in certain contexts though.
I implemented the second design, because I like the idea of keeping all the tcod code together in one module. I just find it a lot easier to read code when I don't have to suddenly parse a bunch of bridging code. I didn't bother creating a separate tcodrs::map module, since I'm not shipping this as API, and I'm only bridging a few functions.
Each Map object has a "peer" reference, which is the pointer to the tcod view of the map. In the constructor for my Map, I made a call to tcodrs::map_new which eventually calls TCOD_map_new, and save the result in the peer instance variable. Theoretically I should be iterating through each tile in the map, and setting the properties in the corresponding peer, but according to the documentation, the default matches my default, so I should be fine. I suppose I could do it to be more robust, in case the tcod default changes, but I don't bother.
By making the appropriate peer call in Map::set_tile, all changes to the Map should be captured by its peer also, as long as I make sure I funnel all changes through set_tile. In general that's a good practice anyway - even inside other Map methods, I try to use the same access functions consistenly. It makes it easier to keep everything in sync, and keep all the properties updated correctly, if there's only one access point.
Also, this is the first time I actually had something outside the tcodrs module need to hold onto a tcod object. I created an opaque tcodrs::Map object to be the peer's type, so I don't have to mess with all this std::os::raw::c_void nonsense.
Since I don't want to recompute the FOV every time, I also need a game state parameter to determine if the FOV should be recomputed. At this point I now have multiple variables all contributing to the game state. I need to refactor this into its own struct, but I also don't want to get distracted with refactoring in the middle of adding some features. After a quick assessment, I don't think I need to redo a lot of work if I refactor later, so I just added a new parameter recompute_fov. If I would have had to do a lot of additional work, I probably would have git stashed, done the refactoring, then reapply my in progress changes.
There's only one surprise left with the borrow checker. I need to read the player parameters to compute the FOV, since it depends on the player's current position. However, if I try to borrow it for the entire loop, I run into a problem later. The player is part of the entities array, so borrowing the player also pins the entire array... which means, later, I can't iterate the entities to draw them. To work around this, if I have to recompute the FOV, I borrow it inside the if block (thus releasing it when the if statement is done), and then I borrow it again at the end of the loop, to handle the keypress.
Fog of war
Fog of war was a pretty straight translation of the Python tutorial. At first I tried to jam it all into a single match statement, but that turned out to be more complex than I wanted. So my final version has a match statement to do the drawing, and then a separate clause to set the explored bit if necessary.
Refactoring the game state
It's now time to pay up and clean up the game state code.
I put all the game elements into a single Game struct: the player, the map, and the FOV flag. The player is actually part of an entities array, and I'm sure we'll be adding other monsters and things to that array, so I put the entire array into the struct.
This brings up what to do with the player reference. In other languages, I would probably create an extra player instance variable, and make that a permanent reference to the first element of the entities. I've now used enough Rust to know that probably won't work. I'll have to somehow guarantee the lifetime is valid for the entire lifetime of entities, and I don't know if there's a way to express that. In any case, I get the sense that I'm really fighting the language if I try to do this.
After reflecting on this some more, I don't see a very good solution. There are going to be some cases where I want to use the first element as a player - for example, for handling player input. On the other hand, there are also going to be cases where I want to treat the player as a uniform entity - for example, when drawing.
I need to support both interfaces. I considered making a player method to get a reference to entities, but I actually don't think it helps that much. For this to be useful, I'd have to also make a method to get the list of entities. I judged my risk of actually changing this implementation detail to be low relatively to the amount of work I'd have to do for a full encapsulation, so instead I just document in a comment that player must remain at entities. It doesn't actually prevent buggy code, but for me this is an acceptable risk level relative to the work to fix.
However, I would understand if someone else took a look and found the lack of encapsulation objectionable. Engineering is full of trade offs, and in this case, I'm trading off time, and lack of Rust knowledge, for a less protected design. The cost and benefit might change as the game develops - something to keep in mind.
I did play around a bit with trying to make interface methods for the different elements, starting with map and entities. Unfortunately, when I try to get a reference to map, it pins the entire object, meaning I could no longer borrow a reference from entities at the same time. It's possible that there's some way around this. However, given my experience level with the borrow checker, I figured that it was just too advanced for me at this time, and really trying to figure that out now was just a big time sink. So that's something on my list to eventually research about Rust, but for today I'm accessing the game elements directly.
At this point in the project, I'm not running into too many surprises. The algorithms in the original tutorial are pretty straightforward, so it's just a matter of porting them over to Rust. After several struggles with the Rust FFI and borrow checker, I feel like I'm starting to get along better with the language. There's still plenty of reference object quirks, but at least I do think that I've reached the point where, even if I'm unable to express things exactly the way I would like, I'm finding reasonable alternatives that are still fairly clean.