By: James Keats | 18 May 2016
The semester is wrapping up, and so I’ve been writing postmortems for pretty much every project I’ve been working on. I’m getting ready to update my portfolio with some of the projects I’ve worked on this past year; I’m a little tired of my website making it look like all I’ve worked in is Flash. One of my favorite pieces I worked on during the last piece of this semester was my UI system for my Game Architecture final, which is written in C++ using SFML.
Strictly speaking, our needs for the UI system were relatively minimal. We needed panels*, text, and a few buttons. This is nothing particularly complicated, and we could’ve made classes for this that were simple and inflexible, especially since this was our last project and we “wouldn’t be touching the code again” (more on that to come). Still, I wanted the experience of making a flexible, intuitive system. My teammate on this project said it was “actually beautiful” and one of the best systems he’s seen, so I think I achieved that at least. Here’s what I wanted overall:
*(I should note that I work in Unity a lot at Champlain and so I tend to use their terminology when talking about UI elements.)
At the end of this project, I had the following classes hooked up into my UI system:
The UIPercentageBarElement was used for player health bars as this was, after all, a fighting game. It simply inherited from Panel and added a bunch of extra functions for easy scaling.
UIElement was the base virtual class that all other elements inherited from. It contained a unique ID for each element (currently stored as a std::string for convenience when getting specific elements to manipulate, but given more time I would’ve stored it as an integer and implemented some sort of hash function for less storage overhead but equally simple lookup ability). The base class was also intended to handle the guaranteed interface for every UI object:
This is all well and good, but you might notice that the constructor for this isn’t in the public members. A first assumption might be that this was just an extra reminder to myself that this class was purely virtual and couldn’t be constructed on its own, and the constructor was kept protected for the children. In fact, all of my element classes have either private or protected constructors. They all, however, have a line in common:
This is, at its core, all the UIManager is: a std::deque that contains pairs of integers, and vectors of elements. It sounds a little heavy-handed, but in the (admittedly somewhat limited) profiling that I did, it turned out to be the fastest solution. If you’re not familiar with a std::deque, here’s my brief (and hopefully correct from my research) explanation:
A std::deque is a double-ended queue, but that name is a little misleading. To paraphrase the more technical cplusplus.com explanation, a deque is similar to a std::vector but allows constant insertion time at the front as well as the rear. It also offers constant time direct access to any elements. Unlike a vector, however, a deque is not stored completely continuously in memory. It maintains smaller “chunks” of continuous memory that link to each other, almost making a hybrid between a std::vector and std::list.
The deque here contains a std::pair of an integer and a vector of elements. I chose this data structure because I found that it was the fastest when profiling and was also conceptually easy to understand and keep sorted, but I am open to suggestions about other types. The important thing to keep in mind here is that the most common operation performed on this collection, draw(), runs quickly and none of the code is too complicated to understand. Let’s look at an example:
(EDIT 5/23/16: Since writing the above description, I did some additional testing using a std::multimap and found that in some, but not all, of the tested scenarios, it performs faster. I am leaving the above description and related code because I think it’s important to document my original thought process and solution, and because my choice was valid based on the data I had at the time. That said, know that the projects I am making based off of this code now use a std::multimap instead.)
This constructs two elements: a panel for the main menu, and a button that goes on that menu. When any variant of the construct element is called, the UIManager will go through the following steps:
This loops through the collection stored in the deque. If it finds an element that has the depth we want, it inserts the new element into the end of that depth’s list of elements. Otherwise, if it determines we’re a new depth that hasn’t been created before, it will create a new pair in the deque for that depth.
This might seem clunky, but it was better than the other methods I tried which involved either a lot of sorting or a lot of middle-insertion which meant a lot of shifting. And there’s this important benefit:
This is all that’s needed for drawing. A simple double-nested for loop. It will always draw every element at the correct relative depth without any extra comparison (other than an
if (mEnabled)) needed.
…but what about initialization? I’m glad you asked.
Initializing each element was a difficult design decision for this system. Each subclass needed different data to initialize itself and by the very nature of this design, I couldn’t use the constructor directly. In the end, I decided to use a somewhat standardized API to handle initialization:
It is somewhat of a pain to have to explicitly call an init function after constructing every element, but it forces you to consider what you need for a given element, and remember to potentially call other necessary setters that aren’t included in the constructor or init functions.
In terms of standardization, I used the following ruleset when creating the init functions:
Using these rules, I designed a system that was, in my and my partner’s opinion, easy to use and understand and was flexible enough to handle a lot of different situations. I am planning to reuse this system, or at least something very similar, going forward. My gut feeling is still that having vectors inside of a deque is a heavy-handed and, in the end, poorly optimized solution that only worked well for the low n’s I was using, and I would like to find a better, long-term solution. Besides that, though, there is very little I would change about this system and I am very happy with the end result.