How I’m doing terminal/console UI

This post is going to cover the UI for my roguelike project. It’s fairly simple stuff, but still interesting enough to write about. Perhaps it will be useful for those developing a console-based game of their own.

I had known since I started the project that I was going to use the ncurses library for rendering, but I also wanted to have an SDL version for portability reasons. To allow use of both of these libraries, I created a very simple portability layer, terminal.hpp, which abstracted away both libraries into a small set of functions. I think most people would be better off skipping this step and instead only use one library.

Let’s talk about drawing to the screen. First off, it’s a terrible idea to use global coords for everything, as that leads to brittle layouts. To avoid this issue, I created a class called term_view which translates global coords into coords relative to a specified sub-rectangle. In other words, it adds an offset to the position of every drawing command. One of the most important features of term_view is that you can define new views in terms of others. This leads to the very useful functions, hsplit and vsplit, which split one term_view into two.


class term_view
{
    // The default view is the entire terminal, which means global coords.
    term_view();

    // Define a term_view in terms of another
    term_view(term_view const& other, rect subrect);

    // Size of our sub-rectangle
    dimen dimensions() const;
    
    // Convert relative coords into global ones.
    auto to_global(coord crd) const;

    // All drawing primitives are issued through term_view
    void draw_char(char32_t ch, coord pos, color_type color) const;
};

// A useful function to split one term_view into two.
std::pair<term_view, term_view> hsplit(term_view const& view, int left_width)
{
    int const right_width = view.dimensions().w - left_width;
    int const height = view.dimensions().h;
    return std::make_pair(
        term_view(view, rect{{0,0},{left_width, height}}),
        term_view(view, rect{{left_width,0},{right_width, height}}));
}

An alternative to term_view is to have a canvas abstraction (also know as framebuffers). When drawing to relative coordinates this way, you first create a “virtual canvas”, draw to it, and then blit the canvas onto the actual console’s canvas. I believe the libtcod library uses this technique, although for my purposes it was unnecessary.

With term_view done, the next step was to create a screen/window/widget system. I created a base for all of these elements called ui_element:

// Base class that every widget, screen, and dialog inherits.
class ui_element
{
public:
    ui_element() = delete;
    ui_element(ui_key&&) {}
    virtual ~ui_element() = default;

    // If is_overlay is false, the ui drawer will only refresh the topmost
    // element. If true, it will refresh underlying elements, which allows
    // windows to exist without graphical artifacts.
    // Have this function return false for the best performance.
    virtual bool is_overlay() const = 0;

    // Draws this element to the term_view. Simple.
    // Note that the term_view is not cleared before this function is called;
    // you may need to clear it yourself.
    virtual void draw(term_view const& view) = 0;

    // wants_command will get called multiple times each keypress for each
    // command mapped to that key.
    // If wants_command returns true, then process_command or
    // process_raw_input will be called, depending on the command_type.
    virtual bool wants_command(command_type cmd) const = 0;
    virtual bool process_command(command_type) { return false; }
    virtual bool process_raw_input(char32_t) { return false; }

    // When a newer element gets popped from the stack, this function gets
    // called immediately, and if it returns true, then this element will
    // get popped too.
    // Example use: Closing a element based on outcome of y/n prompt.
    virtual bool resume_check_destroy() { return false; }
};

ui_elements are created and destroyed using a stack data structure. The nice thing about a stack is that it greatly simplifies object lifetimes; if you push two ui_elements onto the stack then you are guaranteed that the second one will get popped first. I use this property to allow ui_elements to “return” values upon destruction. Originally, this was done in a blocking manner: the push_ui_element function would block on input until the pushed element was destroyed, then return a value. This style is very easy to code with, but unfortunately didn’t work when compiling for emscripten, and so in the end I made it non-blocking with callbacks for return values.

Here’s a list of some ui_elements I used a lot:

  • yesno_popup – A popup window with a custom message that returned a bool.
  • paginator_widget – Formats a long string of text into a scrolling page.
  • select_one_menu – Displays a custom menu of choices and returns the value you select.
  • Additionally, I created some generic ui_elements that transform elements into new ones:

  • closeable_element<T> – Overloads the escape key input of T to destroy it.
  • centered_element<T> – Draws T inside of a sub-rectangle centered on the screen.
  • outlined_element<T> – Draws a 1-character width boarder around element T.
  • Here’s an example of how all of that is used:

    
            element_push<main_menu>(
                [&](main_menu::selection_type choice)
                {
                    switch(choice.second.value)
                    {
                        case main_menu::PLAY:
                            element_push<game_ui>(env());
                            return true;
                        case main_menu::HELP:
    
                        {
                            std::ifstream reader("help.txt", std::ios::in);
                            std::string read_str((std::istreambuf_iterator<char>(reader)),
                                                 (std::istreambuf_iterator<char>()));
    
                            element_push<closeable_element<popup_element<paginator_widget> > >(
                                dimen{ 50, 20 }, box_data::style_data{}, read_str, false);
                            return false;
                        }
                        case main_menu::QUIT:
                            return true;
                    }
                });
    

    Advertisements
    This entry was posted in Uncategorized. Bookmark the permalink.

    Leave a Reply

    Fill in your details below or click an icon to log in:

    WordPress.com Logo

    You are commenting using your WordPress.com account. Log Out / Change )

    Twitter picture

    You are commenting using your Twitter account. Log Out / Change )

    Facebook photo

    You are commenting using your Facebook account. Log Out / Change )

    Google+ photo

    You are commenting using your Google+ account. Log Out / Change )

    Connecting to %s