You can see an index of all the posts in this series: go to index.
In the previous post, I analysed the original BASIC program, identified a few things I need to fix and noted several enhancements I could make.
Both of the games I’ve developed so far in this series have been text-based games that have run in a simple terminal window. This game will also be a text-based game but, unlike the previous two games, it will have a user interface.
Before I started coding my previous two games I made a flowchart to show how the game mechanics worked. As I am creating a game with a user interface, it’s also important to map out the interface before I start. My tool of choice for this sort of work is Omnigraffle, but pencil and paper work just fine. Here’s my UI map for the version of Hexapawn I will be building:
As you can see, my interface map isn’t a work of art and doesn’t need to be, neither does it need to show every detail of every screen. What it does need to show are what elements will go onto each screen and approximately how they will be laid out.
Now I have my interface map, I can use it to walk through typical user journeys to make sure they make sense. For example, here the player starts at the main menu in the centre. From there the menu items will take them to different screens for playing a game (top left), browsing the current state of the AI (top middle), browsing the game history (top right), saving/loading AI and game history (bottom left) or viewing instructions (bottom middle). I’ve also shown how a dialogue window will look (bottom right).
OK, now that I know what I’m building, I’ll get started. I’ve set up a new project on GitHub, and made a local Git repository, and then in the project folder, I’ve created a new file called hexapawn.c.
OK, so how do I begin to build a text-based user interface that supports multiple windows and screens and positioning of text? Well I could try and build a system to do that from the ground up, but that would be an enormous undertaking. One of the key principles I try to keep in mind as my programming projects become more complex is to avoid reinventing the wheel. Fortunately there is already a widely used C library for building text interfaces. It’s called curses. In fact the latest version that I’ll use is called ncurses (for New Curses).
Before I started I checked whether I needed to install or update ncurses on my system. To see what I needed to install, I referred to the packages section on this page and referred to the part relevant to my system. in my case, I was installing on Ubuntu, so I used the following:
sudo apt-get install libncurses5-dev libncursesw5-dev
To see what ncurses does for me, I decided to convert the classic Hello World program to work with the ncurses library. I added the following code to my hexapawn.c file and then saved the file.
// // hexapawn.c // Hexapawn // #include <ncurses.h> int main(void) { // Start ncurses initscr(); // Display message - NOTE printf has now changed to printw printw("Hello, World!\n"); // Wait until 'x' is pressed while (getch() != 'x'); // End ncurses endwin(); return 0; }
To compile this, I need to add the -l option (that’s a lower case L), which specifies a library to search when linking. To use this (starting in my source code directory and with the intent of creating the executable in the parent directory) I compiled with:
gcc hexapawn.c -l ncurses -o ../hexapawn
When I compiled and ran this I saw:
Note that a common fault here is seeing a blank screen, and that’s often because the programmer has used printf and not printw in line 13.
This looks pretty much the same as the original command-line Hello, World program I created, but there are two differences. Firstly, I notice that the program has taken over and cleared the whole terminal window and is displaying the phrase in the top left corner of the window. It’s also waiting for me to enter x before it ends. When I press x to end the program, I can see the previous content of the terminal app has been restored.
Let’s have a look at what’s going on in this new version of Hello World. In line 6, I am now including the header for the ncurses library rather than the usual standard I/O library. In my first two games, all my output was written to the stdout stream, which, as we saw, directed output to the terminal window by default. ncurses takes over the terminal window and maintains its own structures for input and output. By default, output goes to a stream called stdscr. To initialise these, I use the initscr() function on line 10. This sets the terminal to curses mode and clears the screen.
On line 13, I have replaced the normal formatted output command, printf, with ncurses own version, printw. These two functions work identically, except that printf directs output to stdout, while printw directs output to stdscr.
Line 16 introduces a new function, getch. This function will wait until I enter a keypress and then return the character entered. Note that, unlike the functions for reading the keyboard that you’ve seen before, getch does not need you to press enter or return. Note that the while here is an empty loop, since the only thing I’m interested in doing is continually checking the condition. That’s why it’s not followed by braces, just a semicolon.
So this line does nothing except hang around waiting for the user to type a lower case x. Why do I need this? I tried commenting out this line and running the program again. I didn’t see Hello, World! at all, as the program will just ended immediately. This is because, when I end ncurses with the endwin() function on line 19, the stdscr output is lost and no longer visible. To see it, I need to keep the program running until I am ready to end it. For now I am doing this by waiting for an arbitrary key press, but later I’ll provide a cleaner way for the user to exit the program.
I uncommented line 16 and saved, compiled and ran the program again to look at a couple of other features of the default ncurses output. I notice that ncurses has placed a cursor on the screen. When I try typing something, Hello yourself! (making sure that what I was typing type did not include the letter x), I saw this:
ncurses conveniently echoes my typing to the screen. I might not want either of these features to be enabled for a game, Fortunately, it’s easy enough to switch them off, by adding these lines just below the line with initscr:
noecho(); curs_set(0);
So far I’m sure you’re completely underwhelmed. It seems like I’ve used quite a few lines of code to do something I previously achieved with one line. Let me redeem the situation by starting to use the power of ncurses. Suppose I want my Hello, World! greeting to appear in the middle of the screen? I changed the line with printw as shown below:
mvprintw(10, 32, "Hello, World!\n");
When I saved, compiled and ran it, I saw this:
What’s changed? Firstly, in lines 11 and 12, I’ve disabled the echoing of input and the visible cursor. In line 15 I’ve used a slightly different form of the formatted print function. This version, mvprintw wants two additional arguments – y and x coordinates. It first moves the cursor to that location and then outputs the formatted string.
Two things I need to watch out for with all the ncurses functions are that they want the y coordinate first, then the x coordinate, and these are relative to the top-left corner, not the bottom-left. If text does not appear where I expect it to on the screen, the first thing to check is that I haven’t inadvertently swapped the y and x arguments and I am counting rows from the top down not the bottom up.
If positioning text on the screen was all the ncurses library did, it would hardly be worth the effort of using it. Fortunately it does a lot more, and one of its advanced features I’ll be taking advantage of is windows. In addition to stdscr, I can also create and send output to different windows. Let’s see how these work. I changed my code to:
// // main.c // Hexapawn // #include <ncurses.h> int main(void) { // Start ncurses initscr(); noecho(); curs_set(0); refresh(); // Create first window WINDOW * window1 = newwin(10, 15, 5, 12); box(window1, 0, 0); mvwaddstr(window1, 4, 3, "window 1"); wrefresh(window1); // Wait until 'x' is pressed while (getch() != 'x'); // End ncurses endwin(); return 0; }
The function refresh() in line 13 redraws stdscr. Even though I don’t have anything on stdscr at this point I need it here because of an (ahem) “undocumented feature” of ncurses which prevents windows being properly displayed until stdscr has been updated at least once.
In line 16 newwin creates a new window. The arguments passed to this function are the window’s height, width, and the y and x coordinates. It returns a pointer to an area in memory that keeps track of the properties of the window and its contents. In a later part of this series I’ll explore how this works in more detail, but for now just accept that this WINDOW pointer, which I’m storing in the variable window1, is how I will access the window. Another thing I’m ignoring for the moment, is that newwin will return a null pointer if it’s unable to create the window. I’ll deal with error handling in a later post. For now, if I do get a runtime error (i.e. an error that occurs when I run the program, rather then when I compile it), or if a window doesn’t appear, I’ll simply make sure my terminal window is set to a large enough size to show the whole of the window being created.
The box function in line 17 draws a box around the edge of the specified window. I can specify which characters to use to draw the box using the second and third parameters. Setting these to 0, as I’ve done, uses the default characters.
Line 18 introduces another new function, mvwaddstr. This expects to be passed a pointer to a window, the y and x coordinates and an unformatted string. It then writes the string to the specified window at those coordinates. Note that all the other coordinates I’ve used so far have been relative to the top left corner of stdscr. When I use functions that output to a window, coordinates are relative to the top left corner of the window, not the screen.
Any output I send to a window is written to that special area of memory I talked about, but I won’t see that output until I add the window’s content to the screen. This is done using the wrefresh function on line 19.
When I save and compile the file then run this code I see this:
What happens when I add a second window to the mix? I added the following lines of code after the line that calls the wrefresh(window1); function.
// Add a second window WINDOW * window2 = newwin(10, 15, 8, 20); box(window2, 0, 0); mvwaddstr(window2, 4, 3, "window 2"); wrefresh(window2);
When I save, compile and run the code again, I see that part of window 1 is now covered by window 2:
When two or more windows overlap like this, what I see in the overlapping area depends entirely on the order in which the windows are refreshed. I verified this by moving line 19 (wrefresh(window1);) so that it came after line 25 (wrefresh(window2);). When I did this and ran again, I saw that window 1 now appeared in front of window 2.
Now I added another wrefresh(window2); function call after the first two so I had:
wrefresh(window2); wrefresh(window1); wrefresh(window2);
So this should show window2, then show window1 on top of window2, and finally show window2 on top of window1, so the net effect is that window2 will appear on top right? When I ran it, I was surprised to see that window1 was still on top.
What’s going on? Well, whenever I write something to a window, ncurses marks that window as having been changed. Each time I call wrefresh for that window, it marks the window as updated and unchanged. So when it receives the second wrefresh call for window2, it looks at the window and sees that nothing has changed since the last wrefresh and decides there’s no need to redraw the window!
So what do I do if I want to draw window2 on top again, even if nothing new has been written to the window? Well there is a workaround. I added the following line of code just before the second call to wrefresh(window2);:
touchwin(window2);
When I now ran the code again I could see that window2 was back on top. The touchwin function marks the window as if it had changed. Now, when the second call to wrefresh(window2) happens, ncurses sees that the window needs to be updated and writes it back to the screen.
If I had to manage several different stacked windows, you can see that trying to keep track of which order they need to be refreshed in and which ones had been changed or not changed would soon become messy.
Fortunately there is a companion library to ncurses, the panel library, that is intended to take the headache out of exactly this sort of window management. Panels add a capability to give windows a precise depth order (also known as a z-order) and to draw them correctly based on this order. I updated my code with the following changes:
// // main.c // Hexapawn // #include <ncurses.h> #include <panel.h> int main(void) { // Start ncurses initscr(); noecho(); curs_set(0); refresh(); // Create first window and link it to a panel WINDOW * window1 = newwin(10, 15, 5, 12); box(window1, 0, 0); mvwaddstr(window1, 4, 3, "window 1"); wrefresh(window1); PANEL * win1panel = new_panel(window1); // Add a second window and link it to a panel WINDOW * window2 = newwin(10, 15, 8, 20); box(window2, 0, 0); mvwaddstr(window2, 4, 3, "window 2"); wrefresh(window2); PANEL * win2panel = new_panel(window2); int c, order = 1; do { c = getch(); if (c == 's') { top_panel(order ? win1panel : win2panel); update_panels(); doupdate(); order = !order; } } while (c != 'x'); // End ncurses endwin(); return 0; }
In line 7, I am now also including the header for the panel library. In lines 17 to 21 I create window1, draw it and refresh it as before. In line 22 I create a panel for that window. In lines 24 to 28 I do the same for window2.
The routine to read the keyboard, in lines 32 to 41, still checks for the user pressing x to exit, but it now also checks for the s key being pressed to switch the order of the windows. The order variable, keeps track of which window is on top. If it’s 1, then window1 is on top, and if it’s 0 then window2 is on top. Line 39 swaps the order each time s is pressed.
The top_panel function in line 36 brings the specified panel to the top of the stack. The update_panels function in line 37, redraws all the windows according to the current order of the panels and do_update writes all these changes to the actual screen.
Note to successfully compile this new code, I need to explicitly link to the panel library as well:
gcc hexapawn.c -l ncurses -l panel -o ../hexapawn
When I run this code, I can swap the order of the windows simply by pressing s. Note that the windows are redrawn correctly, even though they have not been changed since they were last drawn. When I get bored, I press x to exit.
Now that I have a solid grounding in how ncurses works, in the next post I’ll start using it to build a user interface.
Be First to Comment