Refactoring the rendering code
Up to now, all the rendering code has been in the game loop in the main function. The rendering code is simple and straightforward, so, while it should have been extracted into its own function, there wasn't a pressing need to do so. Now that I'm going to enhance the UI, the rendering needs to be extracted.
I put all the UI widgets like the root window and the offscreen panel as ivars of the Game state. Arguably, this should be in a separate struct, since all the previous data was model data, but these new ivars are view data. I think these are both small and few enough to keep in a single struct for now, but I do want to try to keep them as separate as possible inside the struct.
A few language details about the refactor:
- Structs can't contain traits, so I make the ivars the concrete types Root and Offscreen.
- All the ivars must be initialized at creation. I don't want to use optionals here, since I don't want the rendering code to have to check for initialization.
I created a new system RenderSystem, which has a few creation methods to return the different UI widgets that will be used to initialize the game ivars. Finally, I create an update method, which will be run once each frame, which will do the actual drawing, and move all the current drawing code into that function.
Adding a status panel
The first thing to add is an HP bar. This actually isn't too difficult; it's just a matter of making the right libtcod calls.
I did get worried when I saw that the print functions take variadic arguments. If you're unfamiliar with that term, it's C functions like printf that can take a variable number of arguments. The Rust FFI took me a long time... and I thought I was done having to deal with new language features for that. Then I realized, I don't need to support variadic arguments. Since the arguments are for the different things to put in the string, I required all the string formatting to be done in Rust, so it's not possible to as in format arguments to the libtcod library. That shouldn't be necessary, since Rust's formatting is just as powerful.
Besides that, there wasn't much interesting about this feature. It was pretty much "just work."
Adding a message log
The next thing to add is a message log, with different colors for the different status messages. It's easy enough to keep these as a vector of (String,Color) tuples.
Now, the Python tutorial uses a package called textwrap to wrap all the strings to make sure they fit on a line. Rust doesn't have that builtin (although there's probably a crate somewhere). However, libtcod has a print function that does line wrapping, so I used that function instead.
This made my rendering code a lot more complicated. When I can assume each message takes one line, I can just get the height of my message log panel, and print out that many messages. But because each message can take multiple lines, I have to do this in two passes. First, I need to count how many messages can fit into the log panel. Then, I can go back to display them.
I wanted the new messages to show up and scroll out the old messages, so to do the first sizing step, I iterated from the end of the message log to the beginning. In this case I'll also need the index since I'm going to be using that as the location to start printing from later.
From experience, I know this is a place where it's very easy to create an off by one error. Is the index the:
- First element that I should print?
- Last element just before the one I should print (ie, the first one to exclude)?
To solve these problems, I thought about the edge cases. What happens if everything from the message log fits on screen? Then if I keep index as the first element to print, that would be 0. On the other hand, if it's the last element just before the one I should print, that would be... -1? That's not good. While I'm thinking about edge cases, what happens if the message log is empty? Well this is a bit of a degenerate case, so I'm going to check for it in the rendering, and just return immediately. I could probably make my rendering algorithm work when the log is empty, but I've found that not only is it hard to read, it's also hard to modify, and really isn't worth the trouble. If something really is a special case, just make it explicitly called out in the code; don't jam all the special cases together and have your reader try to figure out if it works by intent, or by accident.
After the first pass to find the index of where to start printing, I made another pass to start printing from that index to the end of the message log.
Adding a mouse look
The last UI feature is the ability to see the objects currently under the mouse cursor.
To do this, I had to switch to an input function that checks for both keyboard and mouse presses. In libtcod, this is check_for_event. However, this is non blocking, which means I also need to set a frame rate; otherwise we might end up spinning the CPU as we try to check for events as quickly as possible.
To make the call to the new check_for_event function, I needed to add an eum for the type of events to check for. Now last time I had some problems with using Rust enums as bitfields. This time, though, I have no duplicate enum values so I think this might be fine. However, what I ran into was that the enum types can't be defined in terms of each other. For example, the C version has KEY_EVENT = KEY_PRESS | KEY_RELEASE. But there's no way to express that in Rust. I finally ended up just expanding all the constants manually. At some point I'll have to break out the bitfields crate but I'm still not prepared to add in cargo yet. Regardless, manually expanding the constants works fine for now. The tcod API takes pointers to where to return the values ("inout" parameters), so I bridge over the Key and Mouse return types and create some default structs to use as results.
Before moving on, I wanted to make sure I hadn't broken anything. So I did a quick test to make sure the keyboard input still works. It does.
Lastly, I added the mouse handling. The return value in Mouse is an x,y result of the grid cell where the mouse is. Now, I've had a bit of a rough time with the FFI, so just to check, I added some printlns of the values right after the check_for_event. It spews once per frame, but the x,y values look correct. After that, I iterated through each of the entities, checked to see if it was in the field of view, and if so, printed the name in the status panel.
I would say adding the UI was a bit of a grind. There wasn't anything particularly interesting... it was mostly the grind of plumbing work that characterizes a big chunk of software engineering.
At some point, I'm going to have to deal with cargo. I've managed to ignore it up to now, but eventually I'll want to use some of these third party crates. My general philosophy is to minimize dependencies, so I'll see how far I get before I find a crate worth adding.